diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml index 053fcdbe0fb..916baf446b7 100644 --- a/.github/actions/install-node-dependencies/action.yml +++ b/.github/actions/install-node-dependencies/action.yml @@ -30,7 +30,7 @@ inputs: outputs: verification-cache-hit: description: Whether pnpm's verification record was restored. - value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + value: ${{ steps.verification-cache.outputs.cache-hit }} verification-cache-path: description: The small pnpm-owned verification record, without registry metadata. value: ${{ steps.verification-cache.outputs.path }} @@ -110,28 +110,11 @@ runs: path: ${{ steps.pnpm-store.outputs.path }} key: node-cache-${{ runner.os }}-${{ steps.pnpm-store.outputs.arch }}-pnpm-${{ hashFiles(inputs.cache-dependency-path) }} - - name: Resolve pnpm verification cache - id: verification-cache - if: >- - inputs.cache-pnpm-verification == 'true' && - (runner.os == 'Linux' || - (runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) || - (runner.os == 'macOS' && runner.arch == 'X64')) - shell: bash - env: - POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} - run: | - printf 'path=%s/lockfile-verified.jsonl\n' "$(pnpm cache path)" >> "$GITHUB_OUTPUT" - printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH" >> "$GITHUB_OUTPUT" - - name: Restore pnpm verification record - id: verification-cache-restore - if: steps.verification-cache.outputs.key != '' - continue-on-error: true - uses: actions/cache/restore@v5 + id: verification-cache + uses: ./.github/actions/restore-pnpm-verification with: - path: ${{ steps.verification-cache.outputs.path }} - key: ${{ steps.verification-cache.outputs.key }} + enabled: ${{ inputs.cache-pnpm-verification }} - name: Validate native runtime shell: bash @@ -169,7 +152,7 @@ runs: # pnpm checks the cached record's policy and validity; never bypass verification. - name: Save pnpm verification record on main - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache-restore.outputs.cache-hit != 'true' + if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache.outputs.cache-hit != 'true' continue-on-error: true uses: actions/cache/save@v5 with: diff --git a/.github/actions/restore-pnpm-verification/action.yml b/.github/actions/restore-pnpm-verification/action.yml new file mode 100644 index 00000000000..fcd428f7c80 --- /dev/null +++ b/.github/actions/restore-pnpm-verification/action.yml @@ -0,0 +1,58 @@ +name: Restore pnpm verification +description: Restore the pnpm-owned lockfile verdict without installing dependencies. + +inputs: + enabled: + description: Restore a verification record on supported runners. + default: 'true' + container-toolchain: + description: Use the manifest's pnpm version and default Linux cache path without installing host pnpm. + default: 'false' + +outputs: + path: + description: The pnpm-owned verification record. + value: ${{ steps.verification-cache.outputs.path }} + key: + description: Exact OS, architecture, pnpm version and policy key. + value: ${{ steps.verification-cache.outputs.key }} + cache-hit: + description: Whether the exact record was restored. + value: ${{ steps.verification-cache-restore.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Resolve pnpm verification cache + id: verification-cache + if: >- + inputs.enabled == 'true' && + (runner.os == 'Linux' || + (runner.os == 'Windows' && (runner.arch == 'X64' || runner.arch == 'ARM64')) || + (runner.os == 'macOS' && runner.arch == 'X64')) + shell: bash + env: + POLICY_HASH: ${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc') }} + CONTAINER_TOOLCHAIN: ${{ inputs.container-toolchain }} + run: | + if [ "$CONTAINER_TOOLCHAIN" = true ]; then + test "$RUNNER_OS" = Linux + # Cache versions include paths, so match the native Linux producer. + cache_path="$(node -p "require('node:path').join(process.env.XDG_CACHE_HOME || require('node:path').join(require('node:os').homedir(), '.cache'), 'pnpm')")" + pnpm_version="$(node -p "require('./package.json').packageManager.match(/^pnpm@([^+]+)/)[1]")" + mkdir -p "$cache_path" + else + cache_path="$(pnpm cache path)" + pnpm_version="$(pnpm --version)" + fi + printf 'path=%s/lockfile-verified.jsonl\n' "$cache_path" >> "$GITHUB_OUTPUT" + printf 'key=pnpm-verification-v1-%s-%s-%s-%s\n' "$RUNNER_OS" "$RUNNER_ARCH" "$pnpm_version" "$POLICY_HASH" >> "$GITHUB_OUTPUT" + + - name: Restore pnpm verification record + id: verification-cache-restore + if: steps.verification-cache.outputs.key != '' + continue-on-error: true + uses: actions/cache/restore@v5 + with: + path: ${{ steps.verification-cache.outputs.path }} + key: ${{ steps.verification-cache.outputs.key }} diff --git a/.github/scripts/render-readme-downloads-badge.mjs b/.github/scripts/render-readme-downloads-badge.mjs index efe0d670cf5..9c98bf9229e 100644 --- a/.github/scripts/render-readme-downloads-badge.mjs +++ b/.github/scripts/render-readme-downloads-badge.mjs @@ -1,5 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { pathToFileURL } from 'node:url' +import { isAgentStateRulesTag } from '../../config/scripts/release-tag-patterns.mjs' const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca' const token = process.env.GITHUB_TOKEN @@ -24,6 +26,22 @@ async function fetchJson(url) { return response.json() } +// Why rules releases are excluded: every running app fetches them every few hours, so they +// count fetches, not installs. +export function countReleaseDownloads(releases) { + let total = 0 + for (const release of releases) { + if (release.draft || isAgentStateRulesTag(release.tag_name ?? '')) { + continue + } + + for (const asset of release.assets ?? []) { + total += asset.download_count ?? 0 + } + } + return total +} + async function getTotalReleaseDownloads() { let page = 1 let total = 0 @@ -37,16 +55,7 @@ async function getTotalReleaseDownloads() { return total } - for (const release of releases) { - if (release.draft) { - continue - } - - for (const asset of release.assets ?? []) { - total += asset.download_count ?? 0 - } - } - + total += countReleaseDownloads(releases) page += 1 } } @@ -104,9 +113,11 @@ function renderBadge(value) { ` } -const total = await getTotalReleaseDownloads() -const badge = renderBadge(formatDownloads(total)) +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const total = await getTotalReleaseDownloads() + const badge = renderBadge(formatDownloads(total)) -await mkdir(dirname(outputPath), { recursive: true }) -await writeFile(outputPath, badge) -console.log(`Rendered ${outputPath} from ${total} downloads.`) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, badge) + console.log(`Rendered ${outputPath} from ${total} downloads.`) +} diff --git a/.github/workflows/agent-state-rules-publish.yml b/.github/workflows/agent-state-rules-publish.yml new file mode 100644 index 00000000000..8a24adbc73f --- /dev/null +++ b/.github/workflows/agent-state-rules-publish.yml @@ -0,0 +1,94 @@ +name: Publish agent state rules + +# The only publisher of agent state rules releases. Merging never publishes: a maintainer runs +# this from main, first to `next` (RC and dev builds), then promotes the identical file to +# `stable` after a soak. Every job runs on the dispatched commit, so the publish job runs exactly +# the script and rules the gate tested. +# +# Why GITHUB_TOKEN: release-policy.yml deletes an agent-state-rules-* release any other author +# publishes. Its releases start no workflow, so release-policy.yml sees them only when someone +# edits one by hand, and then accepts them as bot-authored prereleases. +on: + workflow_dispatch: + inputs: + action: + description: 'publish-next builds from main and publishes to next; promote-stable copies the next file to stable' + required: true + type: choice + options: + - publish-next + - promote-stable + bundled_only: + description: 'publish-next: tell apps to fall back to the rules they shipped with; promote-stable then carries it to stable' + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: agent-state-rules-publish + cancel-in-progress: false + +jobs: + gate: + if: github.ref == 'refs/heads/main' && inputs.action == 'publish-next' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Run the rules gate (schema, regex safety, the bundle, every transcript replay) + shell: bash + run: | + set -euo pipefail + mapfile -t gate_files < <(git ls-files \ + ':(glob)src/main/runtime/agent-state-rules/**/*.test.ts' \ + ':(glob)src/main/runtime/readiness-census*.test.ts' \ + ':(glob)src/main/runtime/*transcript.test.ts' \ + ':(glob)src/main/runtime/*transcripts.test.ts' \ + config/scripts/agent-state-rules-bundle.test.mjs) + pnpm exec vitest run --config config/vitest.config.ts "${gate_files[@]}" + + publish-next: + needs: gate + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Publish to next + env: + GH_TOKEN: ${{ github.token }} + BUNDLED_ONLY: ${{ inputs.bundled_only }} + shell: bash + run: | + set -euo pipefail + flags=() + [[ "$BUNDLED_ONLY" == "true" ]] && flags+=(--bundled-only) + node config/scripts/agent-state-rules-bundle.mjs publish-next "${flags[@]}" + + promote-stable: + if: github.ref == 'refs/heads/main' && inputs.action == 'promote-stable' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Copy the next file to stable + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/agent-state-rules-bundle.mjs promote-stable diff --git a/.github/workflows/ci-cache-warmup.yml b/.github/workflows/ci-cache-warmup.yml index 84ed72cc68b..1c8c248da89 100644 --- a/.github/workflows/ci-cache-warmup.yml +++ b/.github/workflows/ci-cache-warmup.yml @@ -1,14 +1,16 @@ name: Warm shared CI caches on: + # Cache-input pushes seed immediately; this schedule repairs eviction and image changes. schedule: - - cron: '41 * * * *' + - cron: '41 */6 * * *' workflow_dispatch: push: branches: [main] paths: - '.github/workflows/ci-cache-warmup.yml' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-git-compatibility/**' - '.github/actions/prepare-linux-package-fixture/**' @@ -51,7 +53,7 @@ permissions: concurrency: group: ci-cache-warmup-${{ github.event.pull_request.number || github.ref }} - # Hourly retries must let an active warmer finish publishing its caches. + # Scheduled retries must let an active warmer finish publishing its caches. cancel-in-progress: ${{ github.event_name != 'schedule' }} jobs: diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml index 8835c6876b3..9144c36d43a 100644 --- a/.github/workflows/mobile.yml +++ b/.github/workflows/mobile.yml @@ -26,6 +26,7 @@ on: # it or to the release workflow it guards must re-run them. - '.github/workflows/mobile.yml' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/workflows/mobile-ios-release.yml' - 'config/scripts/mobile-release-check-scope*' - 'config/scripts/mobile-test-change-scope*' diff --git a/.github/workflows/node-server-tests.yml b/.github/workflows/node-server-tests.yml index d8d6802576c..16ba68fa6fa 100644 --- a/.github/workflows/node-server-tests.yml +++ b/.github/workflows/node-server-tests.yml @@ -16,6 +16,7 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -35,6 +36,7 @@ on: - '.npmrc' - '.pnpmfile.cjs' - '.github/actions/install-node-dependencies/**' + - '.github/actions/restore-pnpm-verification/**' - '.github/actions/prepare-native-runtime/**' - '.github/actions/prepare-orcad-prebuilds/**' - '.github/workflows/node-server-tests.yml' @@ -74,15 +76,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 outputs: - should_run: ${{ steps.scope.outputs.should_run }} - qualification: ${{ steps.scope.outputs.qualification }} - runners: ${{ steps.scope.outputs.runners }} + should_run: ${{ steps.scope.outputs.should_run || steps.graph.outputs.should_run }} + qualification: ${{ steps.scope.outputs.qualification || steps.graph.outputs.qualification }} + runners: ${{ steps.scope.outputs.runners || steps.graph.outputs.runners }} steps: - uses: actions/checkout@v6 with: fetch-depth: 2 persist-credentials: false - - uses: ./.github/actions/install-node-dependencies + - uses: actions/setup-node@v6 + with: + node-version-file: package.json + package-manager-cache: false - name: Detect headless-server build and test inputs id: scope shell: bash @@ -94,7 +99,7 @@ jobs: # Compare the entire push, including multi-commit pushes and removed files. if git fetch --no-tags --depth=1 origin "$PUSH_BASE" && git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi @@ -102,11 +107,26 @@ jobs: fi # Compare the tested merge with its base, retaining both sides of renames. if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then - node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph else echo 'should_run=true' >> "$GITHUB_OUTPUT" fi + - uses: ./.github/actions/install-node-dependencies + if: steps.scope.outputs.graph_required == 'true' + - name: Check the headless import graph + id: graph + if: steps.scope.outputs.graph_required == 'true' + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + run: | + if [ "$EVENT_NAME" = push ]; then + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification + else + node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" + fi + persistence: needs: changes concurrency: @@ -363,11 +383,19 @@ jobs: with: ref: ${{ inputs.ref }} persist-credentials: false + - uses: ./.github/actions/restore-pnpm-verification + id: pnpm-verification + with: + container-toolchain: 'true' - name: Verify native Alpine artifact and persistence + env: + VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }} run: | + touch "$VERIFICATION_CACHE_PATH" # Multi-arch index digest of the tag; re-resolve it whenever NODE_RUNTIME_PIN moves. docker run --rm --init -i \ -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \ -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'NODE_SERVER_QUALIFICATION' set -eu diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 4228b31fd21..3073d160d11 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -79,6 +79,7 @@ jobs: source_ref: ${{ steps.resolve.outputs.ref }} source_sha: ${{ steps.resolve.outputs.sha }} source_short_sha: ${{ steps.resolve.outputs.short_sha }} + ships_orcad_template: ${{ steps.orcad-template-support.outputs.ships }} steps: # Why inlined (not m-s-abeer/update-gha-summary-with-workflow-inputs): # this job runs with contents:write and secret scope, so avoid executing @@ -783,6 +784,21 @@ jobs: git push origin "$TAG" fi + # Why: a patch cut from a base older than #24155 has no orcad template to build or ship. + - name: Detect whether the tag ships the orcad template + id: orcad-template-support + if: steps.tag.outputs.tag != '' || steps.version.outputs.recovered_tag != '' + env: + TAG: ${{ steps.tag.outputs.tag || steps.version.outputs.recovered_tag }} + run: | + set -euo pipefail + if git cat-file -e "refs/tags/${TAG}^{commit}:config/scripts/packaged-orcad-template.cjs" 2>/dev/null; then + echo "ships=true" >>"$GITHUB_OUTPUT" + else + echo "ships=false" >>"$GITHUB_OUTPUT" + echo "::notice::$TAG predates the orcad template; skipping its build and download." + fi + - name: Release E2E signal summary if: always() run: | @@ -1154,7 +1170,7 @@ jobs: # signing quota, so it runs beside the release gates instead of behind them. orcad-template: needs: cut - if: needs.cut.outputs.should_release == 'true' + if: needs.cut.outputs.should_release == 'true' && needs.cut.outputs.ships_orcad_template == 'true' permissions: contents: read uses: ./.github/workflows/node-server-tests.yml @@ -1203,7 +1219,16 @@ jobs: - orcad-template - release-preflight - relay-windows-process-tree - if: needs.cut.outputs.should_release == 'true' + # Why not the implicit success(): a tag without the orcad template skips that job on purpose. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.cut.outputs.should_release == 'true' && + needs.create-release.result == 'success' && + needs.release-preflight.result == 'success' && + needs.relay-windows-process-tree.result == 'success' && + (needs.orcad-template.result == 'success' || + (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) env: # beforePack and afterPack fail the package when the template is absent. ORCA_REQUIRE_ORCAD_TEMPLATE: '1' @@ -1463,6 +1488,7 @@ jobs: # After the app build so nothing that cleans out/ can drop it; electron-builder ships it. - name: Download the orcad deployment template + if: needs.cut.outputs.ships_orcad_template == 'true' uses: actions/download-artifact@v8 with: name: orcad-template @@ -1806,7 +1832,7 @@ jobs: # each file against the template manifest, so it must record the signed bytes. - name: Reseal the orcad template over its signed binaries id: reseal-orcad-template - if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' + if: matrix.platform == 'win' && github.run_attempt == 1 && steps.restore-signed-inner.outcome == 'success' && needs.cut.outputs.ships_orcad_template == 'true' run: node config/scripts/packaged-orcad-template.cjs --reseal-signed dist/win-unpacked inner-signing-list.txt # The uninstaller must return signed before rebuilding the installer. @@ -2306,7 +2332,16 @@ jobs: - release-preflight # release-mac-build.yml downloads the relay addons from this run. - relay-windows-process-tree - if: needs.cut.outputs.should_release == 'true' + # Why not the implicit success(): a tag without the orcad template skips that job on purpose. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.cut.outputs.should_release == 'true' && + needs.create-release.result == 'success' && + needs.release-preflight.result == 'success' && + needs.relay-windows-process-tree.result == 'success' && + (needs.orcad-template.result == 'success' || + (needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')) # Why: SignPath requires every job in this signing workflow to be # GitHub-hosted. The actual mac build runs in release-mac-build.yml so # Blacksmith stays outside Windows artifact provenance. @@ -2340,6 +2375,15 @@ jobs: - skill-sharing-linux-floor-release-gate - skill-sharing-release-gate - terminal-rendering-golden + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: >- + !cancelled() && + needs.cut.result == 'success' && + needs.build.result == 'success' && + needs.build-mac.result == 'success' && + needs.skill-sharing-linux-floor-release-gate.result == 'success' && + needs.skill-sharing-release-gate.result == 'success' && + needs.terminal-rendering-golden.result == 'success' runs-on: ubuntu-latest permissions: contents: write @@ -2408,7 +2452,8 @@ jobs: needs: - cut - publish-release - if: ${{ needs.cut.outputs.tag != '' }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }} runs-on: ubuntu-latest permissions: actions: write @@ -2437,7 +2482,8 @@ jobs: # A release created with GITHUB_TOKEN does not reliably emit a release # event to other workflows. Dispatch the trusted default-branch workflow; # it validates and checks out the released tag before deploying. - if: ${{ needs.cut.outputs.tag != '' }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }} runs-on: ubuntu-latest permissions: actions: write @@ -2481,7 +2527,8 @@ jobs: needs: - cut - publish-release - if: ${{ needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} + # Why explicit: a skipped orcad-template (tags that predate it) would skip every job after it. + if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && startsWith(needs.cut.outputs.tag, 'v') }} uses: ./.github/workflows/homebrew-bump.yml with: tag: ${{ needs.cut.outputs.tag }} diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index b8555ffbdeb..80694364bae 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -158,6 +158,8 @@ jobs: # Design D2: the parent release-cut run merged it from every node-server lane at this tag. - name: Download the orcad deployment template from the release run + # Why: release-cut skips the template for a tag that predates it. + if: hashFiles('config/scripts/packaged-orcad-template.cjs') != '' uses: actions/download-artifact@v8 with: name: orcad-template diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 53decc5e7e3..512a5fb8ea8 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -22,7 +22,9 @@ jobs: # Why: release events run this file from the tagged commit, so load the module from the same commit. - uses: actions/checkout@v6 with: - sparse-checkout: config/scripts/release-policy.mjs + sparse-checkout: | + config/scripts/release-policy.mjs + config/scripts/release-tag-patterns.mjs sparse-checkout-cone-mode: false persist-credentials: false - name: Enforce release policy diff --git a/.github/workflows/ssh-hostile-hosts.yml b/.github/workflows/ssh-hostile-hosts.yml index 4a54c81c7ed..e55ac8f99e8 100644 --- a/.github/workflows/ssh-hostile-hosts.yml +++ b/.github/workflows/ssh-hostile-hosts.yml @@ -27,6 +27,7 @@ on: - '!src/**/*.test.ts' - 'src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts' - '.github/workflows/ssh-hostile-hosts.yml' + - '.github/actions/restore-pnpm-verification/**' workflow_dispatch: inputs: cells: @@ -99,11 +100,19 @@ jobs: with: name: hostile-hosts-glibc-slot path: out/orcad-prebuilds + - uses: ./.github/actions/restore-pnpm-verification + id: pnpm-verification + with: + container-toolchain: 'true' - name: Build and smoke the linux-x64-musl slot on Alpine + env: + VERIFICATION_CACHE_PATH: ${{ steps.pnpm-verification.outputs.path }} run: | + touch "$VERIFICATION_CACHE_PATH" # Same digest as the headless-server musl lane; re-resolve it whenever NODE_RUNTIME_PIN moves. docker run --rm --init -i \ -e ORCA_BACKGROUND_LAUNCH=1 \ + -v "$VERIFICATION_CACHE_PATH:/root/.cache/pnpm/lockfile-verified.jsonl" \ -v "$GITHUB_WORKSPACE:/work" -w /work \ node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 sh -s <<'MUSL_SLOT' set -eu diff --git a/.github/workflows/ssh-windows-hosts.yml b/.github/workflows/ssh-windows-hosts.yml index 78d9852e019..9e29d130c68 100644 --- a/.github/workflows/ssh-windows-hosts.yml +++ b/.github/workflows/ssh-windows-hosts.yml @@ -38,6 +38,7 @@ on: - 'config/ci/windows-ssh-provider/**' - '.github/workflows/ssh-windows-hosts.yml' - '.github/actions/prepare-orcad-prebuilds/**' + - '.github/actions/restore-pnpm-verification/**' - 'config/scripts/orcad-windows-prebuild-cache.mjs' workflow_dispatch: inputs: @@ -85,10 +86,6 @@ jobs: - uses: actions/checkout@v6 with: persist-credentials: false - - uses: ./.github/actions/install-node-dependencies - id: dependencies - with: - native-runtime: node - name: Self-test the provisioning scripts before touching the machine shell: pwsh run: | @@ -98,6 +95,22 @@ jobs: if($errors.Count){throw "PowerShell parse failed: $($file.FullName)"} } & config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 + # ARM inbox servicing can finish while the independent Node artifacts are prepared. + - name: Prepare the Windows inbox SSH capability + id: inbox-capability + background: true + shell: pwsh + run: | + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){ + $receipts=Join-Path $pwd '.build/ssh-windows-host-receipts' + New-Item -ItemType Directory -Force -Path $receipts | Out-Null + . config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 + Initialize-WindowsInboxSshCapability -Arch '${{ matrix.arch }}' -Receipt (Join-Path $receipts 'inbox-capability-preparation.json') + } + - uses: ./.github/actions/install-node-dependencies + id: dependencies + with: + native-runtime: node # The deploy materializes rung A from this template; only this runner's slot exists here. # The process-tree addon carries the launcher that starts the relay outside sshd's job; the # orcad slot and the relay both stage it, and a standard-user host has no other launch route. @@ -123,6 +136,7 @@ jobs: pnpm build:orcad-prebuilds --require-slots "win32-${{ matrix.arch }}" node config/scripts/build-orcad-template.mjs --targets "win32-${{ matrix.arch }}" pnpm run build:relay + - wait: inbox-capability - name: Run the Windows host cells against a private ${{ matrix.server }} sshd shell: pwsh timeout-minutes: 50 @@ -136,6 +150,8 @@ jobs: $cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_}) if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')} $archive='' + $preparation='' + if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=Join-Path $receipts 'inbox-capability-preparation.json'} if('${{ matrix.server }}' -eq 'preview'){ $archive=Join-Path $env:RUNNER_TEMP 'preview-${{ matrix.archive }}' # The provisioning script refuses the archive unless its sha256 and every binary's match the pin. @@ -145,7 +161,7 @@ jobs: $callback={param($context) & (Join-Path $tools 'invoke-pinned-relay-cells.ps1') -SourceRoot $sourceRoot -Context $context -Target 'win32-${{ matrix.arch }}' -ReceiptRoot $receipts -Cells $cells }.GetNewClosure() - & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') + & (Join-Path $tools 'preview-ssh/prove-preview-openssh.ps1') -Archive $archive -Arch '${{ matrix.arch }}' -Server '${{ matrix.server }}' -Receipt (Join-Path $receipts 'provider-server.json') -InboxPreparationReceipt $preparation -Accounts $cells.Count -HiddenTools @('npm','npx','node-gyp','gcc','g++','cc','c++','make','cl','clang','clang++','msbuild','cmake') -HostCellProbe $callback 2>&1 | Tee-Object (Join-Path $receipts 'provision.log') - uses: actions/upload-artifact@v7 if: always() with: diff --git a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 index e61b1315a42..4ea2fb2231d 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1 @@ -2,46 +2,34 @@ # -HiddenTools: the private accounts are denied every machine PATH directory holding one of these # executables, and their own PATH carries logging shims for them, so SSH sessions have no host toolchain. # -HostCellProbe receives a context hashtable (accounts, port, keys, shim log) once provisioning passes. -param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,5)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe) +param([Parameter(Mandatory=$true)][string]$Receipt,[string]$Archive,[Parameter(Mandatory=$true)][ValidateSet('arm64','x64')][string]$Arch,[ValidateSet('preview','inbox')][string]$Server='preview',[scriptblock]$ProductionRouteProbe,[ValidateRange(1,5)][int]$Accounts=1,[string[]]$HiddenTools=@(),[scriptblock]$HostCellProbe,[string]$InboxPreparationReceipt) $ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1') $target=@{arm64=@{os='Arm64';folder='OpenSSH-ARM64';machine='0xAA64';archive='698c6aec31c1dd0fb996206e8741f4531a97355686b5431ef347d531b07fcd42'};x64=@{os='X64';folder='OpenSSH-Win64';machine='0x8664';archive='23f50f3458c4c5d0b12217c6a5ddfde0137210a30fa870e98b29827f7b43aba5'}}[$Arch] $scopeServer=if($Server -eq 'inbox'){'Windows inbox OpenSSH.Server capability binaries'}else{'Microsoft Win32-OpenSSH 10.0.0.0p2-Preview'} $report = @{scope="$scopeServer $Arch private loopback authentication and stock cmd.exe dispatch"; server=$Server; status='running'; imageVersion=$env:ImageVersion; cleanup=@('not-confirmed'); globalBootstrapCleanup='Not qualified: service bootstrap may create ProgramData SSH and OpenSSH registry entries; disposable CI VM destruction is the boundary'; observations=@(); stages=@(); diagnosticCaptureFailures=@()} $script:receiptWritten=$false function Write-Stage([string]$Stage) { - $timestamp=[DateTime]::UtcNow.ToString('o') - $report.stages += @{stage=$Stage; utc=$timestamp} - try { - $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($report | ConvertTo-Json -Depth 6)) - $temporary="$Receipt.pending" - $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) - try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} - [IO.File]::Move($temporary,$Receipt,$true) - $script:receiptWritten=$true - } catch {Write-Warning 'Progress receipt could not be updated; cleanup must still run'} - Write-Host "Native SSH stage: $Stage ($timestamp)" + if(Write-WindowsSshReceiptStage $report $Receipt $Stage){$script:receiptWritten=$true} } Write-Stage 'preflight-start' if(-not $script:receiptWritten){throw 'Initial progress receipt unavailable; refuse provisioning'} -if ($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $target.os) { throw "Requires isolated native $Arch GitHub runner" } -$admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -if (-not $admin) { throw 'Administrative private service/account setup required' } +Assert-IsolatedWindowsSshCi $Arch Write-Stage 'existing-server-query-start' $inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH' -function Assert-GlobalServerDormant { - $global=Get-CimInstance Win32_Service -Filter "Name='sshd'" - if(-not $global){return} - # Inbox mode may register the global service; it must stay stopped and never be started here. - if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'} -} -Assert-GlobalServerDormant +Assert-WindowsSshGlobalServerDormant $Server Write-Stage 'existing-server-query-complete' Write-Stage 'default-shell-query-start' $openSshKey = 'HKLM:\SOFTWARE\OpenSSH' -$registry = Get-ItemProperty -LiteralPath $openSshKey -ErrorAction SilentlyContinue # Host-cell probes may set DefaultShell per cell; cleanup restores this stock state. -if ($registry.DefaultShell -or $registry.DefaultShellCommandOption) { throw 'Requires stock cmd.exe OpenSSH shell at start' } +Assert-WindowsSshStockShell Write-Stage 'default-shell-query-complete' +if($InboxPreparationReceipt){ + if($Server -ne 'inbox'){throw 'Inbox preparation receipt cannot qualify a preview server'} + $preparation=Get-Content -LiteralPath $InboxPreparationReceipt -Raw | ConvertFrom-Json + if($preparation.status -ne 'passed' -or $preparation.arch -ne $Arch -or $preparation.sourceSha -ne $env:GITHUB_SHA -or $preparation.runId -ne $env:GITHUB_RUN_ID -or $preparation.runAttempt -ne $env:GITHUB_RUN_ATTEMPT -or $preparation.runnerName -ne $env:RUNNER_NAME -or $preparation.imageVersion -ne $env:ImageVersion){throw 'Inbox preparation receipt identity or verdict mismatch'} + $report.inboxCapabilityPreparation=$preparation +} $id = [Guid]::NewGuid().ToString('N').Substring(0,10) $name = "orca$id" $accountNames = @($name) + @(if($Accounts -gt 1){2..$Accounts | ForEach-Object {"$name$_"}}) @@ -180,12 +168,7 @@ try { $report.nativeInputs=$verified Write-Stage 'preview-native-input-verification-complete' } else { - Write-Stage 'inbox-capability-start' - $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' - $report.inboxCapabilityInitialState=[string]$capability.State - if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} - Assert-GlobalServerDormant - Write-Stage 'inbox-capability-complete' + Install-WindowsInboxSshCapability $Arch $report {param($stage) Write-Stage $stage} $verified=@() foreach($binary in @('sshd.exe','ssh.exe','ssh-keygen.exe','sftp.exe','sftp-server.exe')){ $path=Join-Path $sshDir $binary @@ -431,21 +414,38 @@ LogLevel DEBUG1 foreach($directory in $deniedToolDirs){Invoke-Bounded icacls.exe (@($directory.TrimEnd('\'),'/remove:d')+@($ownedAccounts | ForEach-Object {"*$($_.sid)"})) 120 | Out-Null} Write-Stage 'cleanup-toolchain-acl-complete' Write-Stage 'cleanup-user-profile-start' + $profileTargets=@(foreach($account in $ownedAccounts){ + if($account.sid){@{sid=$account.sid;watch=$null;done=$false;profiles=@();waitMs=0;disposition=$null}} + }) $report.profileCleanup=@() - foreach($account in $ownedAccounts){ - if(-not $account.sid){continue} - $profileWait=[Diagnostics.Stopwatch]::StartNew() - do { - $profiles=@(Get-CimInstance Win32_UserProfile | Where-Object SID -eq $account.sid) - if(-not @($profiles | Where-Object Loaded).Count){break} - Start-Sleep -Milliseconds 500 - } while($profileWait.Elapsed.TotalSeconds -lt 30) - $report.profileUnloadWaitMs=$profileWait.ElapsedMilliseconds - $report.privateProfile=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}}) - Write-Stage 'cleanup-user-profile-observed' - $loadedProfiles=@($profiles | Where-Object Loaded) - $report.profileCleanup+=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'} - $profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance + $report.privateProfiles=@() + do { + foreach($entry in @($profileTargets | Where-Object {-not $_.done})){ + if(-not $entry.watch){$entry.watch=[Diagnostics.Stopwatch]::StartNew()} + $profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'") + if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'} + if(@($profiles | Where-Object Loaded).Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue} + # A profile may reload after the polling snapshot. + $profiles=@(Get-CimInstance Win32_UserProfile -Filter "SID='$($entry.sid)'") + if(@($profiles | Where-Object SID -ne $entry.sid).Count){throw 'Private profile query returned an unrelated SID'} + $loadedProfiles=@($profiles | Where-Object Loaded) + if($loadedProfiles.Count -and $entry.watch.ElapsedMilliseconds -lt 30000){continue} + $profiles | Where-Object {-not $_.Loaded} | Remove-CimInstance + $entry.profiles=@($profiles | ForEach-Object {@{loaded=$_.Loaded;status=$_.Status}}) + $entry.waitMs=$entry.watch.ElapsedMilliseconds + $entry.disposition=if($loadedProfiles.Count){'Loaded profile retained for disposable CI VM destruction'}else{'Unloaded profile removed'} + $entry.done=$true + $report.profileCleanup=@($profileTargets | Where-Object done | ForEach-Object disposition) + $report.privateProfiles=@($profileTargets | Where-Object done | ForEach-Object {@{sid=$_.sid;waitMs=$_.waitMs;profiles=$_.profiles;disposition=$_.disposition}}) + $report.profileUnloadWaitMs=$entry.waitMs + $report.privateProfile=$entry.profiles + Write-Stage 'cleanup-user-profile-observed' + } + if(@($profileTargets | Where-Object {-not $_.done}).Count){Start-Sleep -Milliseconds 500} + } while(@($profileTargets | Where-Object {-not $_.done}).Count) + if($profileTargets.Count){ + $report.profileUnloadWaitMs=$profileTargets[-1].waitMs + $report.privateProfile=$profileTargets[-1].profiles } Write-Stage 'cleanup-user-profile-complete' Write-Stage 'cleanup-user-start' diff --git a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 index 0304ac7a275..5bd3ccf30cd 100644 --- a/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 +++ b/config/ci/windows-ssh-provider/preview-ssh/test-preview-diagnostics.ps1 @@ -33,5 +33,166 @@ try { if(($result.hidden -join '|') -ne "$nodeDir|$gccDir"){throw 'Toolchain PATH entries not hidden'} if(($result.kept -join '|') -ne "$plainDir|$(Join-Path $pathRoot 'missing')|Q:\no-such-drive"){throw 'Plain PATH entries not kept in order'} } finally {Remove-Item -LiteralPath $pathRoot -Recurse -Force -ErrorAction SilentlyContinue} +# Mock only the machine boundary; exercise the shared installer without servicing this host. +. (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1') +function Assert-IsolatedWindowsSshCi([string]$Arch){if($script:refuseCapabilityHost){throw 'Injected host refusal'}} +function Assert-WindowsSshGlobalServerDormant([string]$Server){$script:globalChecks++;if($script:globalChecks -eq $script:refuseGlobalCheck){throw 'Injected global server refusal'}} +function Assert-WindowsSshStockShell {$script:shellChecks++;if($script:shellChecks -eq $script:refuseShellCheck){throw 'Injected shell refusal'}} +function Get-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityQueries++;return @{State=$script:capabilityState}} +function Add-WindowsCapability([switch]$Online,[string]$Name){$script:capabilityAdds++;if($script:failCapabilityInstall){throw 'Injected capability install failure'}} +function Reset-CapabilityControl([string]$State){ + $script:capabilityState=$State;$script:capabilityQueries=0;$script:capabilityAdds=0 + $script:globalChecks=0;$script:shellChecks=0;$script:refuseGlobalCheck=0;$script:refuseShellCheck=0 + $script:refuseCapabilityHost=$false;$script:failCapabilityInstall=$false + $script:capabilityStages=[Collections.Generic.List[string]]::new() +} +foreach($state in @('Installed','NotPresent')){ + Reset-CapabilityControl $state + $capabilityReport=@{} + Install-WindowsInboxSshCapability 'x64' $capabilityReport {param($name) $script:capabilityStages.Add($name)} + $expectedAdds=if($state -eq 'Installed'){0}else{1} + if($capabilityReport.inboxCapabilityInitialState -ne $state -or $script:capabilityAdds -ne $expectedAdds -or $script:globalChecks -ne 2 -or $script:shellChecks -ne 2 -or ($script:capabilityStages -join ',') -ne 'inbox-capability-start,inbox-capability-complete'){throw 'Shared capability preparation did not preserve the install and guard boundaries'} +} +foreach($fault in @('host','global-before','shell-before','global-after','shell-after','install')){ + Reset-CapabilityControl 'NotPresent' + switch($fault){ + 'host' {$script:refuseCapabilityHost=$true} + 'global-before' {$script:refuseGlobalCheck=1} + 'shell-before' {$script:refuseShellCheck=1} + 'global-after' {$script:refuseGlobalCheck=2} + 'shell-after' {$script:refuseShellCheck=2} + 'install' {$script:failCapabilityInstall=$true} + } + $rejected=$false + try {Install-WindowsInboxSshCapability 'x64' @{} {param($name) $script:capabilityStages.Add($name)}} catch {$rejected=$true} + if(-not $rejected -or $script:capabilityStages.Contains('inbox-capability-complete')){throw "Capability fault did not fail closed: $fault"} + if($fault -in @('host','global-before','shell-before') -and $script:capabilityAdds){throw 'Capability mutation preceded its host guards'} +} +$capabilityRoot=Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString('N')) +try { + New-Item -ItemType Directory -Path $capabilityRoot | Out-Null + $capabilityReceipt=Join-Path $capabilityRoot 'capability.json' + Reset-CapabilityControl 'Installed' + Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt + $completed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if($completed.status -ne 'passed' -or $completed.inboxCapabilityInitialState -ne 'Installed'){throw 'Capability success receipt missing its observed initial state'} + Reset-CapabilityControl 'NotPresent';$script:failCapabilityInstall=$true + $rejected=$false + try {Initialize-WindowsInboxSshCapability 'x64' $capabilityReceipt} catch {$rejected=$true} + $failed=Get-Content -LiteralPath $capabilityReceipt -Raw | ConvertFrom-Json + if(-not $rejected -or $failed.status -ne 'failed' -or $failed.inboxCapabilityInitialState -ne 'NotPresent' -or $failed.error -ne 'Injected capability install failure'){throw 'Failed capability preparation masqueraded as a passing receipt'} +} finally {Remove-Item -LiteralPath $capabilityRoot -Recurse -Force -ErrorAction SilentlyContinue} +& { + param($ProofAst) + $cleanup=@($ProofAst.FindAll({param($node) $node -is [Management.Automation.Language.TryStatementAst] -and $node.Body.Extent.Text.Contains("Write-Stage 'cleanup-start'")},$true)) + if($cleanup.Count -ne 1 -or -not $cleanup[0].Extent.Text.Contains('[Diagnostics.Stopwatch]::StartNew()')){throw 'Cleanup clock boundary missing'} + # Run the real cleanup gates with virtual clocks; no Windows machine operation may escape these mocks. + $cleanupBlock=[scriptblock]::Create($cleanup[0].Extent.Text.Replace('[Diagnostics.Stopwatch]::StartNew()','(New-ProfileControlStopwatch)').Replace('[DateTime]::UtcNow','(Get-ProfileControlUtcNow)')) + $ownedSids=@('S-1-5-21-100-200-300-1001','S-1-5-21-100-200-300-1002','S-1-5-21-100-200-300-1003') + $foreignSid='S-1-5-21-100-200-300-9000';$control=@{} + function New-ProfileControlStopwatch { + $watch=[pscustomobject]@{StartedMilliseconds=$control.clockMs;Control=$control} + $watch | Add-Member ScriptProperty ElapsedMilliseconds {$this.Control.clockMs-$this.StartedMilliseconds} + return $watch + } + function Get-ProfileControlUtcNow {[DateTime]::new(2026,10,2,0,0,0,[DateTimeKind]::Utc).AddMilliseconds($control.clockMs)} + function Start-Sleep([int]$Milliseconds,[int]$Seconds){$control.clockMs+=$Milliseconds+1000*$Seconds} + function Write-Stage([string]$Stage){$control.stages.Add($Stage)} + function Record-PrivateServiceDiagnostics([switch]$AfterStop){} + function Test-Path([string]$LiteralPath){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected filesystem query'};return $false} + function Get-ItemProperty([string]$LiteralPath,[object]$ErrorAction){if($LiteralPath -ne 'HKLM:\SOFTWARE\OpenSSH'){throw 'Unexpected registry query'};return $null} + function Remove-ItemProperty {throw 'Unexpected registry mutation'} + function Stop-Service([string]$Name,[switch]$Force,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service stop'};if($control.case.Fault -ne 'server-exit'){$control.serverLive=$false}} + function Invoke-Bounded([string]$Program,[string[]]$Arguments){if($Program -ne 'sc.exe' -or ($Arguments -join '|') -ne 'delete|orca-sshd-control'){throw 'Unexpected native command'};if($control.case.Fault -ne 'service-absence'){$control.servicePresent=$false}} + function Get-Process([int]$Id,[object]$ErrorAction){if($Id -ne 100){throw 'Foreign process query'};if($control.serverLive){@{Id=100}}} + function Get-Service([string]$Name,[object]$ErrorAction){if($Name -ne 'orca-sshd-control'){throw 'Foreign service query'};if($control.servicePresent){@{Name=$Name}}} + function Get-ProfileControlLoaded([string]$Sid){ + $state=$control.profiles[$Sid] + if($state.Mode -eq 'late'){return $control.clockMs -lt $state.UnloadAtMs} + if($state.Mode -in @('reload','reload-at-deadline')){return $state.CurrentLoaded} + return $state.Mode -eq 'loaded' + } + function Get-CimInstance([Parameter(Position=0)][string]$ClassName,[string]$Filter){ + if($ClassName -eq 'Win32_Service'){ + if($Filter -ne "Name='orca-sshd-control'"){throw 'Foreign service query'} + if($control.servicePresent){@{PathName=$(if($control.case.Fault -eq 'service-identity'){'C:\foreign\sshd.exe'}else{'C:\fake\ossh-control\sshd.exe'});ProcessId=$(if($control.case.Fault -eq 'service-pid'){200}else{100})}};return + } + if($ClassName -eq 'Win32_Process'){ + if($control.case.Fault -eq 'child-exit'){@{ExecutablePath='C:\fake\OpenSSH\session.exe';ProcessId=101;ParentProcessId=100;CreationDate=(Get-ProfileControlUtcNow)}};return + } + if($ClassName -ne 'Win32_UserProfile' -or $Filter -notmatch "^SID='(S-1-5-21-100-200-300-\d+)'$" -or $Matches[1] -notin $ownedSids){throw 'Profile query must target an owned SID'} + $sid=$Matches[1];$control.clockMs+=$control.case.QueryCostMs;$control.profileQueries[$sid]++ + if($control.case.Fault -eq 'query' -and $sid -eq $ownedSids[1]){throw 'Injected profile query failure'} + if($control.case.Fault -eq 'foreign-result' -and $sid -eq $ownedSids[1]){[pscustomobject]@{SID=$foreignSid;Loaded=$false;Status=0};return} + $state=$control.profiles[$sid] + if($state.Mode -eq 'absent' -or $state.Deleted){return} + $loaded=Get-ProfileControlLoaded $sid + if($state.Mode -eq 'reload' -and $control.profileQueries[$sid] -eq 1){$loaded=$false;$state.CurrentLoaded=$true} + if($state.Mode -eq 'reload-at-deadline' -and $control.clockMs -ge 30000 -and -not $state.Reinjected){$loaded=$false;$state.CurrentLoaded=$true;$state.Reinjected=$true} + [pscustomobject]@{SID=$sid;Loaded=$loaded;Status=0} + } + function Remove-CimInstance { + param([Parameter(ValueFromPipeline=$true)][object]$InputObject) + process { + if($InputObject.SID -notin $ownedSids -or $InputObject.Loaded -or (Get-ProfileControlLoaded $InputObject.SID)){throw 'Unsafe profile deletion attempted'} + if($control.case.Fault -eq 'delete'){throw 'Injected profile deletion failure'} + $control.profiles[$InputObject.SID].Deleted=$true;$control.removed.Add($InputObject.SID) + } + } + function Get-LocalUser([string]$Name,[object]$ErrorAction){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account query'};if($control.users[$Name]){@{Name=$Name}}} + function Remove-LocalUser([string]$Name){if(-not $control.users.ContainsKey($Name)){throw 'Foreign account deletion'};if($control.case.Fault -ne 'account'){$control.users[$Name]=$false}} + function Remove-Item([string]$LiteralPath,[switch]$Recurse,[switch]$Force){if($LiteralPath -ne 'C:\fake\ossh-control'){throw 'Foreign filesystem deletion'};if($control.case.Fault -eq 'keys'){throw 'Injected private key deletion failure'};$control.keysRemoved=$true} + function Assert-ProfileCleanup([hashtable]$Case){ + $control.Clear();$control.case=$Case;$control.clockMs=0;$control.serverLive=$true;$control.servicePresent=$true;$control.keysRemoved=$false + $control.stages=[Collections.Generic.List[string]]::new();$control.removed=[Collections.Generic.List[string]]::new() + $control.profiles=@{};$control.users=@{};$control.profileQueries=@{} + $ownedAccounts=@(for($index=0;$index -lt $ownedSids.Count;$index++){ + $sid=$ownedSids[$index];$control.profileQueries[$sid]=0 + $control.profiles[$sid]=@{Mode=$Case.Modes[$index];UnloadAtMs=$Case.UnloadAtMs[$index];CurrentLoaded=$true;Deleted=$false} + $name="orca-control-$index";$control.users[$name]=$true;@{name=$name;sid=$(if($Case.MissingSid -and $index -eq 2){$null}else{$sid})} + }) + $report=@{status='proof-passed-cleanup-pending'};$openSshKey='HKLM:\SOFTWARE\OpenSSH';$serviceName='orca-sshd-control' + $root='C:\fake\ossh-control';$createdService=$true;$ownedServerPid=100;$sshDir='C:\fake\OpenSSH';$preexisting=@();$deniedToolDirs=@() + & $cleanupBlock + if($Case.Fault){ + if($report.status -ne 'failed' -or $report.cleanupError -ne $Case.Error -or $control.keysRemoved -or $control.stages.Contains('cleanup-private-files-complete')){throw "Cleanup fault did not fail at its gate: $($Case.Name)"} + if($Case.Fault -notin @('account','keys') -and @($control.users.Values | Where-Object {-not $_}).Count){throw "Failure bypassed account cleanup gate: $($Case.Name)"} + return + } + if($report.status -ne 'passed' -or @($control.users.Values | Where-Object {$_}).Count -or -not $control.keysRemoved){throw "Cleanup omitted account/key gates: $($Case.Name)"} + if(($control.removed.ToArray() | Sort-Object) -join ',' -ne (($Case.Removed | Sort-Object) -join ',')){throw "Wrong removed SIDs: $($Case.Name)"} + if(@($report.profileCleanup | Where-Object {$_ -eq 'Loaded profile retained for disposable CI VM destruction'}).Count -ne $Case.Retained){throw "Wrong retained disposition: $($Case.Name)"} + foreach($sid in $Case.FullWindow){ + $observed=@($report.privateProfiles | Where-Object sid -eq $sid) + if($observed.Count -ne 1 -or $observed[0].waitMs -lt 30000){throw "Short profile observation window: $($Case.Name)"} + } + if($control.clockMs -gt $Case.MaxClockMs){throw "Profile windows were serialized: $($Case.Name)"} + if($Case.MissingSid -and $control.profileQueries[$ownedSids[2]]){throw 'Missing SID gained profile deletion authority'} + if($report.privateProfiles.Count -and ($report.profileUnloadWaitMs -ne $report.privateProfiles[-1].waitMs -or ($report.privateProfile | ConvertTo-Json -Compress) -ne ($report.privateProfiles[-1].profiles | ConvertTo-Json -Compress))){throw 'Legacy scalar observation lost owned-account order'} + } + $cases=@( + @{Name='three loaded full windows';Modes=@('loaded','loaded','loaded');Retained=3;Removed=@();FullWindow=$ownedSids}, + @{Name='unload at 29999ms';Modes=@('late','unloaded','absent');UnloadAtMs=@(29999,0,0);Retained=0;Removed=$ownedSids[0..1];FullWindow=@($ownedSids[0])}, + @{Name='reload before deletion';Modes=@('reload','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])}, + @{Name='independent unloads';Modes=@('late','loaded','late');UnloadAtMs=@(5000,0,20000);Retained=1;Removed=@($ownedSids[0],$ownedSids[2]);FullWindow=@($ownedSids[1])}, + @{Name='slow provider queries';Modes=@('loaded','loaded','loaded');QueryCostMs=200;Retained=3;Removed=@();FullWindow=$ownedSids;MaxClockMs=40000}, + @{Name='reload at expired window';Modes=@('reload-at-deadline','unloaded','absent');Retained=1;Removed=@($ownedSids[1]);FullWindow=@($ownedSids[0])}, + @{Name='late unload honestly retained';Modes=@('loaded','loaded','late');UnloadAtMs=@(0,0,45000);Retained=3;Removed=@();FullWindow=$ownedSids}, + @{Name='missing SID is skipped';Modes=@('unloaded','absent','unloaded');Retained=0;Removed=@($ownedSids[0]);MissingSid=$true} + ) + foreach($case in $cases){ + if(-not $case.UnloadAtMs){$case.UnloadAtMs=@(0,0,0)} + if(-not $case.MaxClockMs){$case.MaxClockMs=33000} + Assert-ProfileCleanup $case + } + $failures=@{ + query='Injected profile query failure';delete='Injected profile deletion failure';'foreign-result'='Private profile query returned an unrelated SID' + 'service-identity'='Private service identity changed; refuse stop';'service-pid'='Private service identity changed; refuse stop' + 'server-exit'='Private sshd process still live; no PID-only kill attempted' + 'service-absence'='Private service still registered';'child-exit'='Private SSH child processes remain; preserve files and discard ephemeral runner' + account='Private account still exists';keys='Injected private key deletion failure' + } + foreach($failure in $failures.GetEnumerator()){Assert-ProfileCleanup @{Name=$failure.Key;Fault=$failure.Key;Error=$failure.Value;Modes=@('unloaded','unloaded','unloaded');UnloadAtMs=@(0,0,0)}} +} $ast # Extract functions through the AST: never provision the fixture while testing diagnostics. -'PASS: fixture parse, five numeric-diagnostic cases and the toolchain PATH split' +'PASS: fixture parse, diagnostics, PATH split, capability boundaries, profile windows and cleanup failure gates' diff --git a/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 new file mode 100644 index 00000000000..d2bdc9096a3 --- /dev/null +++ b/config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1 @@ -0,0 +1,61 @@ +function Assert-IsolatedWindowsSshCi([ValidateSet('arm64','x64')][string]$Arch) { + $os=@{arm64='Arm64';x64='X64'}[$Arch] + if($env:GITHUB_ACTIONS -ne 'true' -or $env:ORCA_ISOLATED_SSH_CI -ne '1' -or [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() -ne $os){throw "Requires isolated native $Arch GitHub runner"} + $admin=([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) + if(-not $admin){throw 'Administrative private service/account setup required'} +} + +function Assert-WindowsSshGlobalServerDormant([ValidateSet('preview','inbox')][string]$Server) { + $global=Get-CimInstance Win32_Service -Filter "Name='sshd'" + if(-not $global){return} + $inboxDir=Join-Path $env:WINDIR 'System32\OpenSSH' + # Inbox installation may register the global service; it must never be started here. + if($Server -ne 'inbox' -or $global.State -ne 'Stopped' -or $global.PathName.Trim('"') -ne (Join-Path $inboxDir 'sshd.exe')){throw 'Refuse an existing global SSH server'} +} + +function Assert-WindowsSshStockShell { + $registry=Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\OpenSSH' -ErrorAction SilentlyContinue + if($registry.DefaultShell -or $registry.DefaultShellCommandOption){throw 'Requires stock cmd.exe OpenSSH shell at start'} +} + +function Write-WindowsSshReceiptStage([hashtable]$Report,[string]$Receipt,[string]$Stage) { + $timestamp=[DateTime]::UtcNow.ToString('o') + $Report.stages+=@{stage=$Stage;utc=$timestamp} + try { + $bytes=[Text.UTF8Encoding]::new($false).GetBytes(($Report | ConvertTo-Json -Depth 6)) + $temporary="$Receipt.pending" + $stream=[IO.FileStream]::new($temporary,[IO.FileMode]::Create,[IO.FileAccess]::Write,[IO.FileShare]::Read) + try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()} + [IO.File]::Move($temporary,$Receipt,$true) + $written=$true + } catch {$written=$false;Write-Warning 'Progress receipt could not be updated; cleanup must still run'} + Write-Host "Native SSH stage: $Stage ($timestamp)" + return $written +} + +function Install-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[hashtable]$Report,[scriptblock]$Stage) { + Assert-IsolatedWindowsSshCi $Arch + Assert-WindowsSshGlobalServerDormant 'inbox' + Assert-WindowsSshStockShell + & $Stage 'inbox-capability-start' + $capability=Get-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' + $Report.inboxCapabilityInitialState=[string]$capability.State + if($capability.State -ne 'Installed'){Add-WindowsCapability -Online -Name 'OpenSSH.Server~~~~0.0.1.0' | Out-Null} + Assert-WindowsSshGlobalServerDormant 'inbox' + Assert-WindowsSshStockShell + & $Stage 'inbox-capability-complete' +} + +function Initialize-WindowsInboxSshCapability([ValidateSet('arm64','x64')][string]$Arch,[string]$Receipt) { + $report=@{scope='Windows inbox OpenSSH.Server capability preparation only';arch=$Arch;sourceSha=$env:GITHUB_SHA;runId=$env:GITHUB_RUN_ID;runAttempt=$env:GITHUB_RUN_ATTEMPT;runnerName=$env:RUNNER_NAME;imageVersion=$env:ImageVersion;status='running';stages=@();globalBootstrapCleanup='Disposable CI VM destruction is the boundary; no global sshd is started'} + try { + if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-start')){throw 'Initial progress receipt unavailable; refuse provisioning'} + Install-WindowsInboxSshCapability $Arch $report {param($name) + if(-not (Write-WindowsSshReceiptStage $report $Receipt $name)){throw 'Capability preparation progress receipt unavailable'} + } + $report.status='passed' + } catch {$report.status='failed';$report.error=$_.Exception.Message;throw} + finally { + if(-not (Write-WindowsSshReceiptStage $report $Receipt 'preparation-finished')){throw 'Final capability preparation receipt unavailable'} + } +} diff --git a/config/scripts/agent-state-rules-bundle.mjs b/config/scripts/agent-state-rules-bundle.mjs new file mode 100644 index 00000000000..170d6b4c122 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.mjs @@ -0,0 +1,174 @@ +// Builds and publishes the agent state rules bundle (agent-state-rules.json). The app's loader +// validates the same file (src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts), and +// agent-state-rules-bundle.test.mjs proves the build passes it; this script only assembles the +// file and enforces the publishing rules a single file cannot express. +// +// node config/scripts/agent-state-rules-bundle.mjs build [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs publish-next [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs promote-stable + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const RULES_DIR = join(import.meta.dirname, '..', '..', 'src/main/runtime/agent-state-rules') +export const AGENT_STATE_RULES_ASSET = 'agent-state-rules.json' + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} + +/** The live-updatable agents' files, in release order, under the release's version. */ +export function buildAgentStateRulesBundle({ bundledOnly = false } = {}) { + const release = readJson(join(RULES_DIR, 'agent-state-rules-release.json')) + const files = release.liveUpdatable.map((id) => readJson(join(RULES_DIR, `${id}.json`))) + const bundle = { + version: release.version, + engineVersion: files[0].engineVersion, + ...(bundledOnly ? { bundledOnly: true } : {}), + files + } + return `${JSON.stringify(bundle, null, 2)}\n` +} + +function runGh(args) { + const result = spawnSync('gh', args, { encoding: 'utf8' }) + return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } +} + +function ghOrThrow(gh, args) { + const result = gh(args) + if (result.status !== 0) { + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) + } + return result.stdout +} + +/** The file on `tag`'s release, or null when there is no release yet. */ +function downloadPublished(gh, repo, tag) { + const args = [ + 'release', + 'download', + tag, + '--repo', + repo, + '-p', + AGENT_STATE_RULES_ASSET, + '-O', + '-' + ] + const result = gh(args) + if (result.status === 0) { + return result.stdout + } + // Why throw on anything else, a missing asset included: that means an earlier upload broke. + if (/release not found/i.test(result.stderr)) { + return null + } + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) +} + +/** + * Puts `text` on the channel's release for its engine, as a prerelease that never becomes Latest + * (the app updater follows Latest). `gh` is injectable so the sequence is testable. + */ +export function publishAgentStateRules({ repo, channel, text, target, gh = runGh }) { + const candidate = JSON.parse(text) + const tag = agentStateRulesTag(candidate.engineVersion, channel) + const publishedText = downloadPublished(gh, repo, tag) + // Why strictly higher: apps refuse a version they already have, so republishing one would reach + // nobody, and a lower one would reach only apps that never took the higher. + const published = publishedText === null ? null : JSON.parse(publishedText).version + if (published !== null && candidate.version <= published) { + throw new Error( + `version ${candidate.version} is not higher than the published ${published}; bump agent-state-rules-release.json` + ) + } + const file = join(mkdtempSync(join(tmpdir(), 'agent-state-rules-')), AGENT_STATE_RULES_ASSET) + writeFileSync(file, text) + if (published === null) { + ghOrThrow(gh, [ + 'release', + 'create', + tag, + file, + '--repo', + repo, + '--target', + target, + '--prerelease', + '--latest=false', + '--title', + `Agent state rules (${channel})`, + '--notes', + 'Agent state rules for Orca. Running apps download this file; it is not an app release.' + ]) + } else { + // Why --clobber on the same tag: the app's fixed URL stays valid, and a fetch that lands + // mid-upload gets a 404 and keeps its last good copy. + ghOrThrow(gh, ['release', 'upload', tag, file, '--repo', repo, '--clobber']) + } + return { tag, version: candidate.version } +} + +/** + * Why no rebuild and no re-gate: stable gets exactly the bytes RC and dev builds soaked on next, + * which only the gated publish-next job writes. A bundledOnly next is promoted too: that is how + * stable rolls back to the rules it shipped. + */ +export function promoteAgentStateRules({ repo, engineVersion, target, gh = runGh }) { + const nextTag = agentStateRulesTag(engineVersion, 'next') + const text = downloadPublished(gh, repo, nextTag) + if (text === null) { + throw new Error(`${nextTag} has not been published`) + } + return publishAgentStateRules({ repo, channel: 'stable', text, target, gh }) +} + +function requireEnv(name) { + const value = process.env[name] + if (!value) { + throw new Error(`${name} is not set`) + } + return value +} + +function main([command, ...args]) { + const bundledOnly = args.includes('--bundled-only') + switch (command) { + case 'build': { + const out = args.find((arg) => !arg.startsWith('--')) + if (!out) { + throw new Error('usage: build [--bundled-only]') + } + writeFileSync(out, buildAgentStateRulesBundle({ bundledOnly })) + return + } + case 'publish-next': + case 'promote-stable': { + const repo = requireEnv('GITHUB_REPOSITORY') + const target = requireEnv('GITHUB_SHA') + const text = buildAgentStateRulesBundle({ bundledOnly }) + const { tag, version } = + command === 'publish-next' + ? publishAgentStateRules({ repo, channel: 'next', text, target }) + : promoteAgentStateRules({ repo, engineVersion: JSON.parse(text).engineVersion, target }) + console.log(`Published version ${version} to ${tag}.`) + return + } + default: + throw new Error(`unknown command ${command ?? '(none)'}`) + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/agent-state-rules-bundle.test.mjs b/config/scripts/agent-state-rules-bundle.test.mjs new file mode 100644 index 00000000000..38fd0de6633 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.test.mjs @@ -0,0 +1,179 @@ +// The rules-release gate: the bundle a release would publish validates in the app's own loader, +// carries only agents whose transcripts the census replays, and only the protected workflow can +// publish it. +import { readdirSync, readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + BUNDLED_AGENT_STATE_RULES_VERSION, + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS, + parseAgentStateRulesBundle +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts' +import { BUNDLED_AGENT_STATE_RULE_FILES } from '../../src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts' +import { agentStateRulesDownloadUrl } from '../../src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts' +import { + AGENT_STATE_RULES_ENGINE_VERSION, + UNKNOWN_PANE_RULES_ID +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-schema.ts' +import { CENSUS_TRANSCRIPTS } from '../../src/main/runtime/readiness-census-transcript-catalog.ts' +import { + AGENT_STATE_RULES_ASSET, + buildAgentStateRulesBundle, + promoteAgentStateRules, + publishAgentStateRules +} from './agent-state-rules-bundle.mjs' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const REPO = 'stablyai/orca' +const NEXT = agentStateRulesTag(1, 'next') +const STABLE = agentStateRulesTag(1, 'stable') + +describe('agent state rules bundle build', () => { + it('builds a bundle the app accepts, under the bundled version and engine', () => { + const text = buildAgentStateRulesBundle() + const parsed = parseAgentStateRulesBundle(text, 'live-updatable') + expect(parsed.ok).toBe(true) + const bundle = JSON.parse(text) + expect(bundle.engineVersion).toBe(AGENT_STATE_RULES_ENGINE_VERSION) + expect(bundle.version).toBe(BUNDLED_AGENT_STATE_RULES_VERSION) + expect(bundle.files).toEqual( + BUNDLED_AGENT_STATE_RULE_FILES.filter((file) => + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id) + ) + ) + expect(bundle.bundledOnly).toBeUndefined() + expect(JSON.parse(buildAgentStateRulesBundle({ bundledOnly: true })).bundledOnly).toBe(true) + }) + + it('lets a rules release change only agents the readiness census replays', () => { + const replayed = new Set(CENSUS_TRANSCRIPTS.flatMap((transcript) => transcript.agent ?? [])) + // Why unknown-pane: the census replays every recording on an agent-unknown pane too. + replayed.add(UNKNOWN_PANE_RULES_ID) + expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !replayed.has(id))).toEqual([]) + }) + + it('publishes to the exact URL the app fetches', () => { + for (const channel of ['next', 'stable']) { + expect(agentStateRulesDownloadUrl(channel)).toBe( + `https://github.com/${REPO}/releases/download/${agentStateRulesTag(AGENT_STATE_RULES_ENGINE_VERSION, channel)}/${AGENT_STATE_RULES_ASSET}` + ) + } + }) +}) + +/** A `gh` stand-in over an in-memory set of releases, recording each call. */ +function fakeGh(releases = {}) { + const calls = [] + const gh = (args) => { + calls.push(args) + const [, verb, tag] = args + if (verb === 'download') { + return tag in releases + ? { status: 0, stdout: releases[tag], stderr: '' } + : { status: 1, stdout: '', stderr: 'release not found' } + } + if (verb === 'upload' || verb === 'create') { + expect(args[3].endsWith(`/${AGENT_STATE_RULES_ASSET}`)).toBe(true) + releases[tag] = readFileSync(args[3], 'utf8') + } + return { status: 0, stdout: '', stderr: '' } + } + return { gh, calls, releases } +} + +const at = (version, engineVersion = 1) => + `${JSON.stringify({ version, engineVersion, files: [] })}\n` + +describe('publishAgentStateRules', () => { + it("creates the engine's channel release as a prerelease that can never be Latest", () => { + const fake = fakeGh() + expect( + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(2), + target: 'abc', + gh: fake.gh + }) + ).toEqual({ tag: NEXT, version: 2 }) + expect(fake.calls.find((args) => args[1] === 'create')).toEqual( + expect.arrayContaining([NEXT, '--prerelease', '--latest=false', '--target', 'abc']) + ) + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it('replaces the asset in place on an existing release, keeping the tag', () => { + const fake = fakeGh({ [NEXT]: at(1) }) + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh: fake.gh }) + expect(fake.calls.map((args) => args[1])).toEqual(['download', 'upload']) + expect(fake.calls[1]).toContain('--clobber') + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it.each([1, 2])('refuses version %s over a published 2, uploading nothing', (version) => { + const fake = fakeGh({ [NEXT]: at(2) }) + expect(() => + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(version), + target: 'abc', + gh: fake.gh + }) + ).toThrow('bump agent-state-rules-release.json') + expect(fake.calls.map((args) => args[1])).toEqual(['download']) + }) + + it('fails when the release exists but its download fails', () => { + const gh = () => ({ status: 1, stdout: '', stderr: 'no assets match the file pattern' }) + expect(() => + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh }) + ).toThrow('no assets match') + }) + + it('promotes the identical next bytes to stable', () => { + const nextText = JSON.stringify({ version: 3, engineVersion: 1, files: [] }, null, 2) + const fake = fakeGh({ [NEXT]: nextText, [STABLE]: at(2) }) + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + expect(fake.releases[STABLE]).toBe(nextText) + }) + + it('refuses to promote before next exists', () => { + const fake = fakeGh() + expect(() => + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + ).toThrow('has not been published') + }) +}) + +describe('agent state rules workflows', () => { + const read = (name) => parse(readFileSync(`.github/workflows/${name}`, 'utf8')) + const publish = read('agent-state-rules-publish.yml') + + it('publishes only on manual dispatch from main, in the protected environment', () => { + expect(Object.keys(publish.on)).toEqual(['workflow_dispatch']) + for (const name of ['publish-next', 'promote-stable']) { + const job = publish.jobs[name] + expect(job.environment).toBe('agent-state-rules') + expect(job.permissions).toEqual({ contents: 'write' }) + } + expect(publish.permissions).toEqual({ contents: 'read' }) + expect(publish.jobs.gate.if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['promote-stable'].if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['publish-next'].needs).toBe('gate') + // Why: every job must check out the dispatched commit, so publish runs what the gate tested. + const checkouts = Object.values(publish.jobs).flatMap((job) => + job.steps.filter((step) => step.uses?.startsWith('actions/checkout')) + ) + expect(checkouts.map((step) => step.with?.ref)).toEqual([undefined, undefined, undefined]) + }) + + it('is the only workflow that publishes rules releases', () => { + const publishers = readdirSync('.github/workflows').filter((name) => + /agent-state-rules-bundle\.mjs (?:publish|promote)|release create agent-state-rules/.test( + readFileSync(`.github/workflows/${name}`, 'utf8') + ) + ) + expect(publishers).toEqual(['agent-state-rules-publish.yml']) + }) +}) diff --git a/config/scripts/build-windows-cli-launcher.mjs b/config/scripts/build-windows-cli-launcher.mjs index b93005600e9..3d9d93aaed2 100644 --- a/config/scripts/build-windows-cli-launcher.mjs +++ b/config/scripts/build-windows-cli-launcher.mjs @@ -5,6 +5,7 @@ import { createHash } from 'node:crypto' import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' export function windowsCliLauncherFingerprint(inputPaths, version) { const hash = createHash('sha256').update(version) @@ -66,6 +67,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) join(crateRoot, 'build.rs'), manifestPath, join(crateRoot, 'app.manifest'), + join(crateRoot, '.cargo', 'config.toml'), iconPath, join(repoRoot, 'config/scripts/build-windows-cli-launcher.mjs') ], @@ -117,6 +119,16 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) process.exit(result.status ?? 1) } - copyFileSync(join(targetDirectory, 'release', 'orca.exe'), outputPath) + const builtPath = join(targetDirectory, 'release', 'orca.exe') + const vcRuntimeImports = findDynamicVcRuntimeImports( + readPeImportedDllNames(readFileSync(builtPath)) + ) + if (vcRuntimeImports.length > 0) { + // Why fatal: those DLLs ship with the Visual C++ Redistributable, so the CLI would fail to start on a clean Windows install. + throw new Error( + `orca.exe imports ${vcRuntimeImports.join(', ')}; the C runtime must be linked statically (native/windows-cli-launcher/.cargo/config.toml).` + ) + } + copyFileSync(builtPath, outputPath) writeFileSync(`${outputPath}.sha256`, fingerprint) } diff --git a/config/scripts/build-windows-cli-launcher.test.mjs b/config/scripts/build-windows-cli-launcher.test.mjs index e83b57f65e7..0e06cafd22e 100644 --- a/config/scripts/build-windows-cli-launcher.test.mjs +++ b/config/scripts/build-windows-cli-launcher.test.mjs @@ -17,6 +17,7 @@ import { windowsCliLauncherFileVersion, windowsCliLauncherFingerprint } from './build-windows-cli-launcher.mjs' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' const itCrossHost = process.platform === 'win32' ? it.skip : it const projectRoot = resolve(import.meta.dirname, '../..') @@ -131,6 +132,9 @@ describe('Windows CLI launcher', () => { expect(info.ProductVersion).toBe(version) const binary = readFileSync(launcherPath) expect(binary.includes(Buffer.from('requestedExecutionLevel level="asInvoker"'))).toBe(true) + const imports = readPeImportedDllNames(binary) + expect(imports.map((name) => name.toLowerCase())).toContain('kernel32.dll') + expect(findDynamicVcRuntimeImports(imports)).toEqual([]) const icon = readFileSync(join(projectRoot, 'resources', 'build', 'icon.ico')) const imageSize = icon.readUInt32LE(14) const imageOffset = icon.readUInt32LE(18) diff --git a/config/scripts/ci-cache-warmup-workflow.test.mjs b/config/scripts/ci-cache-warmup-workflow.test.mjs index 74b15e9b267..fd027a8434f 100644 --- a/config/scripts/ci-cache-warmup-workflow.test.mjs +++ b/config/scripts/ci-cache-warmup-workflow.test.mjs @@ -72,12 +72,12 @@ it('bounds warming to the required platforms and validates changes without grant expect(workflow.permissions).toEqual({ contents: 'read' }) expect(workflow.on.push.branches).toEqual(['main']) expect(workflow.on.push.paths).toContain('.github/actions/prepare-native-runtime/**') - expect(workflow.on.schedule).toEqual([{ cron: '41 * * * *' }]) + expect(workflow.on.schedule).toEqual([{ cron: '41 */6 * * *' }]) expect(workflow.on.pull_request.paths).toContain('.github/workflows/ci-cache-warmup.yml') expect(steps[0].with['persist-credentials']).toBe(false) }) -it('lets hourly warmers wait while pushes, PR updates, and manual runs can replace active work', () => { +it('lets scheduled warmers wait while pushes, PR updates, and manual runs can replace active work', () => { expect(workflow.concurrency).toEqual({ group: 'ci-cache-warmup-${{ github.event.pull_request.number || github.ref }}', 'cancel-in-progress': "${{ github.event_name != 'schedule' }}" diff --git a/config/scripts/ci-native-cache.test.mjs b/config/scripts/ci-native-cache.test.mjs index 6851ed62f9b..045cc5de6d8 100644 --- a/config/scripts/ci-native-cache.test.mjs +++ b/config/scripts/ci-native-cache.test.mjs @@ -123,8 +123,8 @@ describe('CI native cache ownership', () => { installerPath, Buffer.from( readFileSync(installerPath, 'utf8').replace( - "inputs.cache-pnpm-verification == 'true'", - "inputs.cache-pnpm-verification == 'false'" + 'enabled: ${{ inputs.cache-pnpm-verification }}', + "enabled: 'false'" ) ) ) diff --git a/config/scripts/ci-pnpm-verification-cache.test.mjs b/config/scripts/ci-pnpm-verification-cache.test.mjs index c64d019d06c..b878b3ef046 100644 --- a/config/scripts/ci-pnpm-verification-cache.test.mjs +++ b/config/scripts/ci-pnpm-verification-cache.test.mjs @@ -1,21 +1,116 @@ -import { readFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { parse } from 'yaml' +import { classifyPrJobs, PR_CHECK_JOBS } from './pr-code-change-scope.mjs' +import { runProcessSync } from './script-child-process.mjs' const action = parse(readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8')) const steps = action.runs.steps -const resolve = steps.find((step) => step.id === 'verification-cache') -const restore = steps.find((step) => step.id === 'verification-cache-restore') +const cache = steps.find((step) => step.id === 'verification-cache') +const cacheAction = parse( + readFileSync('.github/actions/restore-pnpm-verification/action.yml', 'utf8') +) +const resolve = cacheAction.runs.steps.find((step) => step.id === 'verification-cache') +const restore = cacheAction.runs.steps.find((step) => step.id === 'verification-cache-restore') const install = steps.find((step) => step.name === 'Install dependencies') const save = steps.find((step) => step.name === 'Save pnpm verification record on main') -const evaluate = (expression, context) => - runInNewContext( - expression.replaceAll('inputs.cache-pnpm-verification', 'inputs["cache-pnpm-verification"]'), - context - ) +const evaluate = (expression, context) => runInNewContext(expression, context) + +function resolveRecord({ + container = false, + os = 'Linux', + packageManager = 'pnpm@12.8.1+sha512.test' +} = {}) { + const root = mkdtempSync(join(tmpdir(), 'orca-verification-record-')) + try { + const output = join(root, 'outputs') + const calls = join(root, 'pnpm-calls') + const cacheRoot = join(root, 'cache with spaces') + const cachePath = join(cacheRoot, 'pnpm') + writeFileSync(join(root, 'package.json'), JSON.stringify({ packageManager })) + const execution = runProcessSync({ + program: 'bash', + args: [ + '-e', + '-o', + 'pipefail', + '-c', + ` + pnpm() { + printf '%s\\n' "$*" >> "$TEST_CALLS" + case "$*" in + 'cache path') printf '%s\\n' "$TEST_CACHE_PATH" ;; + '--version') printf '%s\\n' '12.8.1' ;; + *) return 2 ;; + esac + } + ${resolve.run} + ` + ], + cwd: root, + env: { + ...process.env, + RUNNER_OS: os, + RUNNER_ARCH: 'ARM64', + POLICY_HASH: 'policy-digest', + GITHUB_OUTPUT: output, + TEST_CALLS: calls, + TEST_CACHE_PATH: cachePath, + CONTAINER_TOOLCHAIN: String(container), + XDG_CACHE_HOME: cacheRoot + } + }) + return { + code: execution.code, + output: existsSync(output) ? readFileSync(output, 'utf8').replaceAll(root, '') : '', + calls: existsSync(calls) ? readFileSync(calls, 'utf8') : '' + } + } finally { + rmSync(root, { recursive: true, force: true }) + } +} describe('pnpm-owned verification record', () => { + it('qualifies every shared-installer consumer when verification restoration changes', () => { + const result = classifyPrJobs(['.github/actions/restore-pnpm-verification/action.yml']) + for (const job of PR_CHECK_JOBS) { + expect(result[job], job).toBe(true) + } + }) + + it.skipIf(process.platform === 'win32')( + 'shares the exact key and archive path with containers without invoking host pnpm', + () => { + const host = resolveRecord() + const container = resolveRecord({ container: true }) + expect(host.code).toBe(0) + expect(container.code).toBe(0) + expect(container.output).toBe(host.output) + expect(container.output).toContain( + 'path=/cache with spaces/pnpm/lockfile-verified.jsonl' + ) + expect(container.output).toContain( + 'key=pnpm-verification-v1-Linux-ARM64-12.8.1-policy-digest' + ) + expect(host.calls).toBe('cache path\n--version\n') + expect(container.calls).toBe('') + } + ) + + it.skipIf(process.platform === 'win32').each([ + { container: true, os: 'Windows' }, + { container: true, os: 'macOS' }, + { container: true, packageManager: 'npm@12.8.1' } + ])('rejects an unsupported container identity: %j', (options) => { + const result = resolveRecord(options) + expect(result.code).not.toBe(0) + expect(result.output).toBe('') + expect(result.calls).toBe('') + }) + it.each([ ['Linux', 'X64', true], ['Linux', 'ARM64', true], @@ -31,7 +126,7 @@ describe('pnpm-owned verification record', () => { expect( evaluate(resolve.if, { runner: { os, arch }, - inputs: { 'cache-pnpm-verification': enabled } + inputs: { enabled } }) ).toBe(expected && enabled === 'true') } @@ -44,12 +139,15 @@ describe('pnpm-owned verification record', () => { expect(resolve.run).toContain('"$(pnpm cache path)"') expect(resolve.run).toContain('lockfile-verified.jsonl') expect(resolve.run).toContain('pnpm-verification-v1-%s-%s-%s-%s') - expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$(pnpm --version)" "$POLICY_HASH"') + expect(resolve.run).toContain('pnpm_version="$(pnpm --version)"') + expect(resolve.run).toContain('"$RUNNER_OS" "$RUNNER_ARCH" "$pnpm_version" "$POLICY_HASH"') + expect(cache.uses).toBe('./.github/actions/restore-pnpm-verification') + expect(cache.with.enabled).toBe('${{ inputs.cache-pnpm-verification }}') expect(restore.uses).toBe('actions/cache/restore@v5') expect(restore.with.path).toBe('${{ steps.verification-cache.outputs.path }}') expect(restore.with['restore-keys']).toBeUndefined() expect(restore['continue-on-error']).toBe(true) - expect(steps.indexOf(restore)).toBeLessThan(steps.indexOf(install)) + expect(steps.indexOf(cache)).toBeLessThan(steps.indexOf(install)) expect(install.if).toBeUndefined() expect(install.run).toContain('pnpm install --frozen-lockfile --ignore-scripts') }) @@ -64,21 +162,20 @@ describe('pnpm-owned verification record', () => { const context = { github: { event_name: event, ref }, steps: { - 'verification-cache': { outputs: { key: 'a-key' } }, - 'verification-cache-restore': { outputs: { 'cache-hit': 'false' } } + 'verification-cache': { outputs: { key: 'a-key', 'cache-hit': 'false' } } } } const expression = save.if .replaceAll( - 'steps.verification-cache-restore.outputs.cache-hit', - 'steps["verification-cache-restore"].outputs["cache-hit"]' + 'steps.verification-cache.outputs.cache-hit', + 'steps["verification-cache"].outputs["cache-hit"]' ) .replaceAll('steps.verification-cache.outputs.key', 'steps["verification-cache"].outputs.key') expect(evaluate(expression, context)).toBe(expected) context.steps['verification-cache'].outputs.key = '' expect(evaluate(expression, context)).toBe(false) context.steps['verification-cache'].outputs.key = 'a-key' - context.steps['verification-cache-restore'].outputs['cache-hit'] = 'true' + context.steps['verification-cache'].outputs['cache-hit'] = 'true' expect(evaluate(expression, context)).toBe(false) expect(save.uses).toBe('actions/cache/save@v5') expect(steps.indexOf(save)).toBeGreaterThan(steps.indexOf(install)) diff --git a/config/scripts/node-server-change-scope.mjs b/config/scripts/node-server-change-scope.mjs index 7badba9fa98..78c5e572677 100644 --- a/config/scripts/node-server-change-scope.mjs +++ b/config/scripts/node-server-change-scope.mjs @@ -1,12 +1,6 @@ -import { build } from 'esbuild' import { appendFileSync, globSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { - externalNativeAddons, - ORCAD_CHILD_ENTRY_POINTS, - ORCAD_ENTRY_POINT -} from './orcad-entry-build.mjs' import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { nodeServerQualification } from './node-server-qualification.mjs' @@ -42,6 +36,7 @@ const ALWAYS_FILES = new Set([ ]) const ALWAYS_PREFIXES = [ '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', '.github/actions/prepare-orcad-prebuilds/', // These areas also contain worker paths and fixtures opened without an import. @@ -68,6 +63,8 @@ export function discoverNodeServerTests(root = ROOT) { } export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) { + const [{ build }, { externalNativeAddons, ORCAD_CHILD_ENTRY_POINTS, ORCAD_ENTRY_POINT }] = + await Promise.all([import('esbuild'), import('./orcad-entry-build.mjs')]) const entries = entryPoints ?? [ ORCAD_ENTRY_POINT, ...Object.values(ORCAD_CHILD_ENTRY_POINTS), @@ -99,7 +96,11 @@ export async function collectNodeServerInputs({ root = ROOT, entryPoints } = {}) ) } -export async function classifyNodeServerChanges(changedFiles, collect = collectNodeServerInputs) { +export async function classifyNodeServerChanges( + changedFiles, + collect = collectNodeServerInputs, + { deferGraph = false } = {} +) { if (changedFiles.length === 0) { return { shouldRun: true, reason: 'No complete changed-file evidence' } } @@ -113,6 +114,9 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN if (forced) { return { shouldRun: true, reason: `Build or CI input changed: ${forced}` } } + if (deferGraph) { + return { graphRequired: true, reason: 'Installed dependencies are needed to check imports' } + } try { const inputs = await collect() const matched = changedFiles.find((file) => inputs.has(file)) @@ -133,12 +137,16 @@ export async function classifyNodeServerChanges(changedFiles, collect = collectN if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const changedFiles = readFileSync(process.argv[2], 'utf8').split('\0').filter(Boolean) - const result = await classifyNodeServerChanges(changedFiles) + const result = await classifyNodeServerChanges(changedFiles, collectNodeServerInputs, { + deferGraph: process.argv.includes('--defer-graph') + }) console.log(result.reason) const policy = nodeServerQualification(changedFiles, result, { fullQualification: process.argv.includes('--full-qualification') }) - const output = `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` + const output = result.graphRequired + ? 'graph_required=true\n' + : `should_run=${result.shouldRun}\nqualification=${policy.qualification}\nrunners=${JSON.stringify(policy.runners)}\n` if (process.env.GITHUB_OUTPUT) { appendFileSync(process.env.GITHUB_OUTPUT, output) } else { diff --git a/config/scripts/node-server-change-scope.test.mjs b/config/scripts/node-server-change-scope.test.mjs index 0ff118ed678..88ec2c446bb 100644 --- a/config/scripts/node-server-change-scope.test.mjs +++ b/config/scripts/node-server-change-scope.test.mjs @@ -1,4 +1,4 @@ -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, beforeAll, describe, expect, it } from 'vitest' @@ -11,6 +11,8 @@ import { import { nodeServerTestPaths } from './node-server-test-paths.mjs' import { ORCAD_CHILD_ENTRY_POINTS } from './orcad-entry-build.mjs' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { runProcessSync } from './script-child-process.mjs' +import { NODE_SERVER_RUNNERS } from './node-server-qualification.mjs' const temporaryDirs = [] afterEach(() => { @@ -83,6 +85,7 @@ it.each([ 'config/patches/node-pty@1.1.0.patch', 'native/windows-registry/src/addon.cc', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/restore-pnpm-verification/action.yml', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', '.github/workflows/node-server-tests.yml', @@ -91,6 +94,80 @@ it.each([ expect((await classifyNodeServerChanges([file], async () => new Set())).shouldRun).toBe(true) }) +it('defers uncertain paths without issuing a qualification verdict', async () => { + const result = await classifyNodeServerChanges( + ['src/renderer/src/example.ts'], + async () => { + throw new Error('graph must not run before installation') + }, + { deferGraph: true } + ) + expect(result.graphRequired).toBe(true) + expect(result.shouldRun).toBeUndefined() +}) + +it.each([ + { files: [], deferred: true, expected: 'should_run=true' }, + { files: ['package.json'], deferred: true, expected: 'should_run=true' }, + { + files: ['src/main/persistence/profile-state/profile-state-windows.ts'], + deferred: true, + fullQualification: false, + qualification: false, + runners: ['ubuntu-22.04', 'windows-2022', 'windows-11-arm'], + expected: 'should_run=true' + }, + { + files: ['src/main/persistence/profile-state/profile-state-windows.ts'], + deferred: true, + expected: 'should_run=true' + }, + { + files: ['src/main/providers/provider-windows.ts'], + deferred: true, + fullQualification: false, + expected: 'should_run=true' + }, + { files: ['src/renderer/src/example.ts'], deferred: true, expected: 'graph_required=true' }, + { files: ['src/renderer/src/example.ts'], deferred: false, expected: 'should_run=true' } +])('fails closed or requests dependencies in an uninstalled checkout: %j', (scenario) => { + const root = moduleTree( + Object.fromEntries( + ['node-server-change-scope', 'node-server-test-paths', 'node-server-qualification'].map( + (name) => [ + `config/scripts/${name}.mjs`, + readFileSync(new URL(`./${name}.mjs`, import.meta.url), 'utf8') + ] + ) + ) + ) + const changes = join(root, 'changes') + const stepOutput = join(root, 'step-output') + writeFileSync(changes, scenario.files.map((file) => `${file}\0`).join('')) + const result = runProcessSync({ + program: process.execPath, + args: [ + realpathSync(join(root, 'config/scripts/node-server-change-scope.mjs')), + changes, + ...(scenario.fullQualification === false ? [] : ['--full-qualification']), + ...(scenario.deferred ? ['--defer-graph'] : []) + ], + cwd: root, + env: { ...process.env, GITHUB_OUTPUT: stepOutput }, + timeoutMs: 5_000 + }) + expect(result.code).toBe(0) + const output = readFileSync(stepOutput, 'utf8') + expect(output).toContain(scenario.expected) + if (scenario.expected === 'graph_required=true') { + expect(output).not.toContain('should_run=') + expect(output).not.toContain('runners=') + } else { + expect(output).toContain(`qualification=${scenario.qualification !== false}`) + expect(output).toContain(`runners=${JSON.stringify(scenario.runners ?? NODE_SERVER_RUNNERS)}`) + } +}) + describe('the actual Bun build and profile-test dependency graph', () => { let inputs beforeAll(async () => { @@ -149,7 +226,7 @@ it('keeps every platform job and runs them when detection is skipped or fails', expect(detect.env.PUSH_BASE).toBe('${{ github.event.before }}') expect(detect.run).toContain('git fetch --no-tags --depth=1 origin "$PUSH_BASE"') expect(detect.run).toContain('git diff --name-only --no-renames -z "$PUSH_BASE" HEAD') - expect(detect.run).toContain('node-server-changes" --full-qualification') + expect(detect.run).toContain('node-server-changes" --defer-graph --full-qualification') expect(workflow.on.pull_request.types).toContain('ready_for_review') expect(workflow.on.schedule).toHaveLength(1) // A pull request may qualify one platform, so the merged commit must re-qualify all six. diff --git a/config/scripts/node-server-qualification.mjs b/config/scripts/node-server-qualification.mjs index 9901b609a9b..cb556e6d1d3 100644 --- a/config/scripts/node-server-qualification.mjs +++ b/config/scripts/node-server-qualification.mjs @@ -12,6 +12,7 @@ const BUILD_PREFIXES = [ 'native/', 'config/patches/', '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', '.github/actions/prepare-orcad-prebuilds/' ] diff --git a/config/scripts/node-server-qualification.test.mjs b/config/scripts/node-server-qualification.test.mjs index 6018fe6a7e4..b7692af0d46 100644 --- a/config/scripts/node-server-qualification.test.mjs +++ b/config/scripts/node-server-qualification.test.mjs @@ -27,6 +27,7 @@ it.each([ 'native/windows-registry/src/addon.cc', 'config/patches/node-pty.patch', '.github/actions/install-node-dependencies/action.yml', + '.github/actions/restore-pnpm-verification/action.yml', '.github/actions/prepare-native-runtime/action.yml', '.github/actions/prepare-orcad-prebuilds/action.yml', 'src/main/ssh/ssh-provider.ts', diff --git a/config/scripts/orcad-template-release-workflow.test.mjs b/config/scripts/orcad-template-release-workflow.test.mjs index eee8d451fc3..311231ea4bf 100644 --- a/config/scripts/orcad-template-release-workflow.test.mjs +++ b/config/scripts/orcad-template-release-workflow.test.mjs @@ -123,6 +123,65 @@ describe('orcad template release wiring (design D2)', () => { expect(releaseMac.permissions.actions).toBe('read') }) + it('skips the template only for a tag that predates it', () => { + const cutSteps = releaseCut.jobs.cut.steps + const push = stepIndex(cutSteps, (step) => step.name === 'Push tag') + const detect = stepIndex(cutSteps, (step) => step.id === 'orcad-template-support') + expect(detect).toBeGreaterThan(push) + expect(cutSteps[detect].run).toContain(':config/scripts/packaged-orcad-template.cjs"') + expect(releaseCut.jobs.cut.outputs.ships_orcad_template).toBe( + '${{ steps.orcad-template-support.outputs.ships }}' + ) + expect(releaseCut.jobs['orcad-template'].if).toContain( + "needs.cut.outputs.ships_orcad_template == 'true'" + ) + + for (const name of ['build', 'build-mac']) { + const condition = releaseCut.jobs[name].if + // Every other dependency still has to succeed, as under the implicit success(). + for (const need of releaseCut.jobs[name].needs.filter((need) => need !== 'orcad-template')) { + expect(condition).toContain(`needs.${need}.result == 'success'`) + } + expect(condition).toContain("needs.orcad-template.result == 'success'") + expect(condition).toContain( + "(needs.orcad-template.result == 'skipped' && needs.cut.outputs.ships_orcad_template == 'false')" + ) + } + + const buildSteps = releaseCut.jobs.build.steps + for (const step of [ + buildSteps.find((step) => step.name === 'Download the orcad deployment template'), + buildSteps.find((step) => step.id === 'reseal-orcad-template') + ]) { + expect(step.if).toContain("needs.cut.outputs.ships_orcad_template == 'true'") + } + const macDownload = releaseMac.jobs['build-mac'].steps.find( + (step) => step.with?.name === 'orcad-template' + ) + expect(macDownload.if).toBe("hashFiles('config/scripts/packaged-orcad-template.cjs') != ''") + }) + + it('keeps every job downstream of the template from inheriting its skip', () => { + const needsOf = (name) => [releaseCut.jobs[name].needs ?? []].flat() + const dependsOnTemplate = (name) => + needsOf(name).some((need) => need === 'orcad-template' || dependsOnTemplate(need)) + const downstream = Object.keys(releaseCut.jobs).filter(dependsOnTemplate) + expect(downstream).toEqual( + expect.arrayContaining(['build', 'build-mac', 'publish-release', 'homebrew-bump']) + ) + for (const name of downstream) { + // A skipped ancestor skips the job under the implicit success() that any `if` without a + // status function gets, so each one must override it and check its own needs instead. + const condition = releaseCut.jobs[name].if + expect(condition, name).toContain('!cancelled()') + for (const need of needsOf(name).filter( + (need) => need !== 'orcad-template' && need !== 'cut' + )) { + expect(condition, `${name} -> ${need}`).toContain(`needs.${need}.result == 'success'`) + } + } + }) + it('signs only Windows template binaries and reseals the manifest before the installer rebuild', () => { const steps = releaseCut.jobs.build.steps const stage = steps.find((step) => step.id === 'stage-inner') diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs index 7f34a8304a9..c6448f367ef 100644 --- a/config/scripts/pr-code-change-scope.mjs +++ b/config/scripts/pr-code-change-scope.mjs @@ -38,6 +38,7 @@ const ALWAYS_ON_CODE_JOBS = new Set(['static_analysis', 'typecheck', 'test']) const GLOBAL_FORCE_PREFIXES = [ '.github/workflows/pr.yml', '.github/actions/install-node-dependencies/', + '.github/actions/restore-pnpm-verification/', '.github/actions/prepare-native-runtime/', 'config/scripts/pr-code-change-scope' ] diff --git a/config/scripts/readme-downloads-badge.test.mjs b/config/scripts/readme-downloads-badge.test.mjs new file mode 100644 index 00000000000..736ccbbd9b2 --- /dev/null +++ b/config/scripts/readme-downloads-badge.test.mjs @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { countReleaseDownloads } from '../../.github/scripts/render-readme-downloads-badge.mjs' + +const asset = (downloads) => ({ download_count: downloads }) + +describe('README downloads badge', () => { + it('counts published app releases and skips drafts and agent state rules releases', () => { + expect( + countReleaseDownloads([ + { tag_name: 'v1.4.1', draft: false, assets: [asset(10), asset(5)] }, + { tag_name: 'v1.4.2-rc.0', draft: false, assets: [asset(3)] }, + { tag_name: 'mobile-v0.0.1', draft: false, assets: [asset(2)] }, + { tag_name: 'v1.4.3', draft: true, assets: [asset(100)] }, + { tag_name: 'agent-state-rules-engine-1-next', draft: false, assets: [asset(9000)] }, + { tag_name: 'agent-state-rules-engine-1-stable', draft: false, assets: [asset(9000)] } + ]) + ).toBe(20) + }) +}) diff --git a/config/scripts/release-e2e-dispatch-contract.test.mjs b/config/scripts/release-e2e-dispatch-contract.test.mjs index 2277cd399a4..d939a6231ef 100644 --- a/config/scripts/release-e2e-dispatch-contract.test.mjs +++ b/config/scripts/release-e2e-dispatch-contract.test.mjs @@ -28,7 +28,9 @@ describe('release E2E dispatch contract', () => { expect(releaseWorkflow.jobs.e2e).toBeUndefined() expect(dispatchJob.needs).toEqual(['cut', 'publish-release']) - expect(dispatchJob.if).toBe("${{ needs.cut.outputs.tag != '' }}") + expect(dispatchJob.if).toBe( + "${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' }}" + ) expect(dispatchJob.permissions.actions).toBe('write') expect(dispatchStep.env.TAG).toBe('${{ needs.cut.outputs.tag }}') expect(dispatchStep.run).toContain('gh workflow run e2e.yml') diff --git a/config/scripts/release-policy.mjs b/config/scripts/release-policy.mjs index 4733e79f2b4..e9ef5d8264e 100644 --- a/config/scripts/release-policy.mjs +++ b/config/scripts/release-policy.mjs @@ -1,15 +1,19 @@ // Enforced by .github/workflows/release-policy.yml on release events. +import { + DESKTOP_RC_TAG as RC_TAG, + DESKTOP_STABLE_TAG as STABLE_TAG, + MOBILE_TAG, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + const BOT_LOGIN = 'github-actions[bot]' const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' -const NUMBER = '(?:0|[1-9][0-9]*)' -const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` -const STABLE_TAG = new RegExp(`^v${VERSION}$`) -const RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) -const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) +// Why agent state rules here: their publish workflow is a bot author, and as a prerelease the +// release can never become Latest, which the app updater follows. export function isPrereleaseTag(tag) { - return RC_TAG.test(tag) || MOBILE_TAG.test(tag) + return RC_TAG.test(tag) || MOBILE_TAG.test(tag) || isAgentStateRulesTag(tag) } export function compareStableTags(left, right) { diff --git a/config/scripts/release-policy.test.mjs b/config/scripts/release-policy.test.mjs index 6478f6bb0c4..6d36717d785 100644 --- a/config/scripts/release-policy.test.mjs +++ b/config/scripts/release-policy.test.mjs @@ -147,4 +147,41 @@ describe('release policy', () => { await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.100') }) + + it('keeps a bot-published agent state rules release as a prerelease, off Latest', async () => { + const rules = release('agent-state-rules-engine-1-next', 'github-actions[bot]') + const stable = release('v1.4.214', 'github-actions[bot]') + const github = createGithub({ + releases: [rules, stable], + tags: { 'v1.4.214': {} } + }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled() + expect(github.rest.git.deleteRef).not.toHaveBeenCalled() + expect(github.rest.repos.updateRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id, + prerelease: true, + make_latest: 'false' + }) + expect(github.rest.repos.updateRelease).toHaveBeenLastCalledWith({ + ...repoRef, + release_id: stable.id, + make_latest: 'true' + }) + }) + + it('deletes an agent state rules release a person published', async () => { + const rules = release('agent-state-rules-engine-1-stable', 'someone', { prerelease: true }) + const github = createGithub({ releases: [rules] }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id + }) + }) }) diff --git a/config/scripts/release-tag-pattern-census.test.mjs b/config/scripts/release-tag-pattern-census.test.mjs new file mode 100644 index 00000000000..b42a424a36f --- /dev/null +++ b/config/scripts/release-tag-pattern-census.test.mjs @@ -0,0 +1,184 @@ +// Census: every release-triggered workflow and every script that lists this repo's releases +// classifies tags through release-tag-patterns.mjs, so a new tag family such as the agent state +// rules cannot reach a desktop-only path through one that forgot it. +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { + DESKTOP_RC_TAG, + DESKTOP_STABLE_TAG, + DESKTOP_STABLE_TAG_SHELL_PATTERN, + agentStateRulesTag, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + +const WORKFLOWS_DIR = '.github/workflows' +const SCRIPT_DIRS = ['config/scripts', '.github/scripts'] +const SHARED_IMPORT = /from '[^']*release-tag-patterns\.mjs'/ +const LISTS_RELEASES = /\/releases\?|listReleases|releases\.atom|'release',\s*'list'/ + +const RULES_TAGS = [agentStateRulesTag(1, 'next'), agentStateRulesTag(1, 'stable')] +const rulesRelease = (tag, extra = {}) => ({ + tag_name: tag, + name: tag, + draft: false, + prerelease: true, + author: { login: 'github-actions[bot]' }, + assets: [{ name: 'agent-state-rules.json', download_count: 5 }], + ...extra +}) + +function read(path) { + return readFileSync(path, 'utf8') +} + +function importsSharedPatterns(path) { + return SHARED_IMPORT.test(read(path)) +} + +function isReleaseTriggered(workflow) { + const on = workflow.on + if (typeof on === 'string') { + return on === 'release' + } + if (Array.isArray(on)) { + return on.includes('release') + } + return typeof on === 'object' && on !== null && 'release' in on +} + +function releaseTriggeredWorkflows() { + return readdirSync(WORKFLOWS_DIR) + .filter((name) => /\.ya?ml$/.test(name)) + .map((name) => join(WORKFLOWS_DIR, name)) + .filter((path) => isReleaseTriggered(parse(read(path)))) +} + +function scriptsNamedIn(text) { + return [...text.matchAll(/((?:config|\.github)\/scripts\/[\w.-]+\.mjs)/g)].map( + (match) => match[1] + ) +} + +function releaseListingScripts() { + return SCRIPT_DIRS.flatMap((dir) => + readdirSync(dir) + .filter((name) => name.endsWith('.mjs') && !name.includes('.test.')) + .map((name) => join(dir, name)) + ).filter((path) => LISTS_RELEASES.test(read(path))) +} + +/** + * Scripts that list releases yet admit only desktop tags by their own shape. Each entry proves, + * by calling the script, that a rules release never passes; a new listing script must import the + * shared patterns or add a proof here. + */ +const DESKTOP_ONLY_PROOFS = { + 'config/scripts/create-draft-release.mjs': async () => { + const { latestPreviousPublishedDesktopReleaseTag } = await import('./create-draft-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag)), + { tag_name: 'v1.4.1', draft: false } + ] + expect(latestPreviousPublishedDesktopReleaseTag(releases, 'v1.4.2')).toBe('v1.4.1') + }, + 'config/scripts/publish-complete-draft-releases.mjs': async () => { + const { isReleaseCutDraft } = await import('./publish-complete-draft-releases.mjs') + for (const tag of RULES_TAGS) { + expect(isReleaseCutDraft(rulesRelease(tag, { draft: true }))).toBe(false) + } + }, + 'config/scripts/latest-stable-release.mjs': async () => { + const { latestStableDesktopReleaseTag } = await import('./latest-stable-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag, { prerelease: false })), + { tag_name: 'v1.4.1' } + ] + expect(latestStableDesktopReleaseTag(releases)).toBe('v1.4.1') + }, + 'config/scripts/assert-github-release-is-draft.mjs': async () => { + const { matchingDesktopReleases } = await import('./assert-github-release-is-draft.mjs') + expect( + matchingDesktopReleases( + RULES_TAGS.map((tag) => rulesRelease(tag)), + 'v1.4.1' + ) + ).toEqual([]) + }, + 'config/scripts/verify-release-required-assets.mjs': async () => { + const { verifyRequiredReleaseAssets } = await import('./verify-release-required-assets.mjs') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(RULES_TAGS.map((tag) => rulesRelease(tag))))) + ) + await expect( + verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag: 'v1.4.1', token: '' }) + ).rejects.toThrow('was not found') + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('release tag pattern census', () => { + it('finds the release-triggered workflows and release-listing scripts it guards', () => { + expect(releaseTriggeredWorkflows().length).toBeGreaterThan(0) + expect(releaseListingScripts().length).toBeGreaterThan(0) + }) + + it.each(releaseTriggeredWorkflows())('%s classifies tags through the shared patterns', (path) => { + const text = read(path) + const viaScript = scriptsNamedIn(text).some(importsSharedPatterns) + const viaShellPattern = text.includes(DESKTOP_STABLE_TAG_SHELL_PATTERN) + expect( + viaScript || viaShellPattern, + `${path} names no script importing release-tag-patterns.mjs and embeds no shared pattern` + ).toBe(true) + }) + + it.each(releaseTriggeredWorkflows())( + '%s checks out the shared patterns beside its script', + (path) => { + const workflow = parse(read(path)) + const sparse = Object.values(workflow.jobs) + .flatMap((job) => job.steps ?? []) + .map((step) => step.with?.['sparse-checkout']) + .filter((value) => typeof value === 'string') + for (const paths of sparse) { + if (scriptsNamedIn(paths).some(importsSharedPatterns)) { + expect(paths).toContain('config/scripts/release-tag-patterns.mjs') + } + } + } + ) + + it.each(releaseListingScripts())( + '%s imports the shared patterns or proves it admits only desktop tags', + async (path) => { + if (importsSharedPatterns(path)) { + return + } + const proof = DESKTOP_ONLY_PROOFS[path] + expect( + proof, + `${path} lists releases: import release-tag-patterns.mjs or add a proof` + ).toBeDefined() + await proof() + } + ) + + it('keeps no proof for a script that no longer lists releases', () => { + const listing = new Set(releaseListingScripts()) + expect(Object.keys(DESKTOP_ONLY_PROOFS).filter((path) => !listing.has(path))).toEqual([]) + }) + + it('never classifies an agent state rules tag as a desktop release', () => { + for (const tag of RULES_TAGS) { + expect(isAgentStateRulesTag(tag)).toBe(true) + expect(DESKTOP_STABLE_TAG.test(tag) || DESKTOP_RC_TAG.test(tag)).toBe(false) + } + expect(() => agentStateRulesTag(1, 'beta')).toThrow() + }) +}) diff --git a/config/scripts/release-tag-patterns.mjs b/config/scripts/release-tag-patterns.mjs new file mode 100644 index 00000000000..43bae852b5f --- /dev/null +++ b/config/scripts/release-tag-patterns.mjs @@ -0,0 +1,30 @@ +// The tag families published as GitHub releases of this repo. Every release-triggered workflow and +// every script that lists releases classifies tags through this file, so a new family (like the +// agent state rules) is excluded or admitted in one place; release-tag-pattern-census.test.mjs +// enforces that. + +const NUMBER = '(?:0|[1-9][0-9]*)' +const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` + +/** The stable desktop tag as a bash `[[ =~ ]]` pattern, for workflows that gate inline. */ +export const DESKTOP_STABLE_TAG_SHELL_PATTERN = + '^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$' + +export const DESKTOP_STABLE_TAG = new RegExp(DESKTOP_STABLE_TAG_SHELL_PATTERN) +export const DESKTOP_RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) +export const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) + +/** The agent state rules bundle, one release per rules engine and channel, updated in place. */ +const AGENT_STATE_RULES_TAG = /^agent-state-rules-engine-[1-9][0-9]*-(?:next|stable)$/ + +export function isAgentStateRulesTag(tag) { + return AGENT_STATE_RULES_TAG.test(tag) +} + +export function agentStateRulesTag(engineVersion, channel) { + const tag = `agent-state-rules-engine-${engineVersion}-${channel}` + if (!isAgentStateRulesTag(tag)) { + throw new Error(`not an agent state rules tag: ${tag}`) + } + return tag +} diff --git a/config/scripts/ssh-windows-hosts-workflow.test.mjs b/config/scripts/ssh-windows-hosts-workflow.test.mjs index 93f3c40e768..33a113d232c 100644 --- a/config/scripts/ssh-windows-hosts-workflow.test.mjs +++ b/config/scripts/ssh-windows-hosts-workflow.test.mjs @@ -14,6 +14,14 @@ const workflow = parse( ) const job = workflow.jobs.hosts const runStep = job.steps.find((step) => step.name?.startsWith('Run the Windows host cells')) +const provisioning = readFileSync( + join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/prove-preview-openssh.ps1'), + 'utf8' +) +const capability = readFileSync( + join(projectDir, 'config/ci/windows-ssh-provider/preview-ssh/windows-ssh-capability.ps1'), + 'utf8' +) const manifest = (arch) => JSON.parse( readFileSync( @@ -48,6 +56,99 @@ describe('SSH Windows-host workflow', () => { 'x64/windows-2022/preview' ]) expect(job.env).toMatchObject({ ORCA_BACKGROUND_LAUNCH: '1', ORCA_ISOLATED_SSH_CI: '1' }) + expect(job.strategy['fail-fast']).toBe(false) + expect(job['timeout-minutes']).toBe(75) + expect(runStep['timeout-minutes']).toBe(50) + }) + + it('overlaps only guarded ARM inbox capability preparation with the existing builds', () => { + const selfTestIndex = job.steps.findIndex((step) => step.name?.startsWith('Self-test')) + const prepareIndex = job.steps.findIndex((step) => step.id === 'inbox-capability') + const installIndex = job.steps.findIndex( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + const buildIndex = job.steps.findIndex((step) => step.name?.startsWith('Build this runner')) + const prebuildIndex = job.steps.findIndex( + (step) => step.uses === './.github/actions/prepare-orcad-prebuilds' + ) + const templateIndex = job.steps.findIndex((step) => step.name?.startsWith('Build the win32')) + const waitIndex = job.steps.findIndex((step) => step.wait === 'inbox-capability') + const runIndex = job.steps.indexOf(runStep) + expect([ + selfTestIndex, + prepareIndex, + installIndex, + buildIndex, + prebuildIndex, + templateIndex, + waitIndex, + runIndex + ]).toEqual([1, 2, 3, 4, 5, 6, 7, 8]) + expect(job.steps[prepareIndex]).toMatchObject({ + background: true, + shell: 'pwsh' + }) + expect(job.steps[prepareIndex].if).toBeUndefined() + expect(job.steps[waitIndex].if).toBeUndefined() + expect(job.steps[prepareIndex].run.trim()).toMatch( + /^if\('\$\{\{ matrix\.server }}' -eq 'inbox' -and '\$\{\{ matrix\.arch }}' -eq 'arm64'\)\{[\s\S]+\}$/ + ) + expect(job.steps[prepareIndex].run).toContain('Initialize-WindowsInboxSshCapability') + expect(job.steps[prepareIndex].run).toContain('inbox-capability-preparation.json') + expect(runStep.run).toContain('-InboxPreparationReceipt $preparation') + expect(runStep.run).toContain( + "$preparation=Join-Path $receipts 'inbox-capability-preparation.json'" + ) + expect(runStep.run).toContain( + "if('${{ matrix.server }}' -eq 'inbox' -and '${{ matrix.arch }}' -eq 'arm64'){$preparation=" + ) + expect(runStep.background).toBeUndefined() + expect(job.steps.at(-1)).toMatchObject({ if: 'always()', uses: 'actions/upload-artifact@v7' }) + }) + + it('shares one capability installer without bypassing native verification or private cleanup', () => { + expect(capability.match(/Add-WindowsCapability -Online/g)).toHaveLength(1) + expect(provisioning).not.toContain('Add-WindowsCapability') + expect(provisioning).toContain(". (Join-Path $PSScriptRoot 'windows-ssh-capability.ps1')") + expect(provisioning).toContain('Install-WindowsInboxSshCapability $Arch $report') + const install = capability.slice( + capability.indexOf('function Install-WindowsInboxSshCapability'), + capability.indexOf('function Initialize-WindowsInboxSshCapability') + ) + const mutation = install.indexOf('Add-WindowsCapability') + expect(install.indexOf('Assert-IsolatedWindowsSshCi')).toBeLessThan(mutation) + expect(install.indexOf('Assert-WindowsSshGlobalServerDormant')).toBeLessThan(mutation) + expect(install.lastIndexOf('Assert-WindowsSshGlobalServerDormant')).toBeGreaterThan(mutation) + expect(install.indexOf('Assert-WindowsSshStockShell')).toBeLessThan(mutation) + expect(install.lastIndexOf('Assert-WindowsSshStockShell')).toBeGreaterThan(mutation) + for (const check of [ + '(Machine $path) -ne $target.machine', + 'Get-AuthenticodeSignature -LiteralPath $path', + 'Inbox native input Microsoft signature invalid', + "Write-Stage 'host-cell-probe-start'", + "Write-Stage 'cleanup-default-shell-start'", + "Write-Stage 'cleanup-service-stop-delete-start'" + ]) { + expect(provisioning).toContain(check) + } + }) + + it('keeps preparation provenance separate from the oracle current capability state', () => { + for (const [field, environment] of [ + ['sourceSha', 'GITHUB_SHA'], + ['runId', 'GITHUB_RUN_ID'], + ['runAttempt', 'GITHUB_RUN_ATTEMPT'], + ['runnerName', 'RUNNER_NAME'], + ['imageVersion', 'ImageVersion'] + ]) { + expect(capability).toContain(`${field}=$env:${environment}`) + expect(provisioning).toContain(`$preparation.${field} -ne $env:${environment}`) + } + expect(provisioning).toContain("$preparation.status -ne 'passed'") + expect(provisioning).toContain('$preparation.arch -ne $Arch') + expect(provisioning).toContain('$report.inboxCapabilityPreparation=$preparation') + expect(capability).toContain('$Report.inboxCapabilityInitialState=[string]$capability.State') + expect(capability).toContain("$report.status='failed';$report.error=$_.Exception.Message;throw") }) it('fetches the preview release its hash manifests pin', () => { diff --git a/config/scripts/telemetry-bundle-constant-patterns.mjs b/config/scripts/telemetry-bundle-constant-patterns.mjs index 87c2ae43cf7..f0e9d53ed1c 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.mjs @@ -2,5 +2,6 @@ // them, but the injected identity and key remain adjacent in the declaration. export const BUILD_IDENTITY_RE = /\b(?:const|let|var)\s+BUILD_IDENTITY\s*=\s*["`](rc|stable)["`]/ export const WRITE_KEY_RE = /\b(?:const|let|var)\s+WRITE_KEY\s*=\s*["`](phc_[A-Za-z0-9_-]+)["`]/ +// Why the `,` alternative: the identity can be a later declarator in a shared `var` (`var a=!0,b=\`stable\``). export const MINIFIED_TELEMETRY_RE = - /\b(?:const|let|var)\s+[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/ + /(?:\b(?:const|let|var)\s+|,\s*)[$\w]+\s*=\s*["'`](rc|stable)["'`][\s\S]{0,200}?[,$]\s*[$\w]+\s*=\s*["'`](phc_[A-Za-z0-9_-]+)["'`]/ diff --git a/config/scripts/telemetry-bundle-constant-patterns.test.mjs b/config/scripts/telemetry-bundle-constant-patterns.test.mjs index ca87e3ee971..57f8e0c7217 100644 --- a/config/scripts/telemetry-bundle-constant-patterns.test.mjs +++ b/config/scripts/telemetry-bundle-constant-patterns.test.mjs @@ -22,4 +22,16 @@ describe('telemetry bundle constant patterns', () => { const bundle = 'var dde=`stable`,fde=`phc_example-key_123`,pde=(dde===`stable`)' expect(bundle).toMatch(MINIFIED_TELEMETRY_RE) }) + + it('accepts the identity as a later declarator in a shared declaration', () => { + const bundle = 'var wpe=!0,Tpe=`stable`,Epe=`phc_example-key_123`,Dpe=(Tpe===`stable`)' + expect(MINIFIED_TELEMETRY_RE.exec(bundle)?.slice(1, 3)).toEqual([ + 'stable', + 'phc_example-key_123' + ]) + }) + + it('rejects a minified identity with no adjacent write key', () => { + expect('var a=!0,b=`stable`,c=null').not.toMatch(MINIFIED_TELEMETRY_RE) + }) }) diff --git a/config/scripts/windows-pe-imports.mjs b/config/scripts/windows-pe-imports.mjs new file mode 100644 index 00000000000..340bc08a2ae --- /dev/null +++ b/config/scripts/windows-pe-imports.mjs @@ -0,0 +1,54 @@ +// Why a hand-rolled reader: the release guard only needs the import table's DLL +// names, and a parser dependency would be a new supply-chain input for one check. +const IMPORT_DIRECTORY_INDEX = 1 + +export function readPeImportedDllNames(buffer) { + const peOffset = buffer.readUInt32LE(0x3c) + if (buffer.toString('latin1', peOffset, peOffset + 4) !== 'PE\0\0') { + throw new Error('Not a PE image') + } + const sectionCount = buffer.readUInt16LE(peOffset + 6) + const optionalHeaderSize = buffer.readUInt16LE(peOffset + 20) + const optionalHeader = peOffset + 24 + const isPe32Plus = buffer.readUInt16LE(optionalHeader) === 0x20b + const dataDirectories = optionalHeader + (isPe32Plus ? 112 : 96) + const importRva = buffer.readUInt32LE(dataDirectories + IMPORT_DIRECTORY_INDEX * 8) + if (importRva === 0) { + return [] + } + const sections = [] + for (let index = 0; index < sectionCount; index += 1) { + const header = optionalHeader + optionalHeaderSize + index * 40 + sections.push({ + virtualAddress: buffer.readUInt32LE(header + 12), + size: Math.max(buffer.readUInt32LE(header + 8), buffer.readUInt32LE(header + 16)), + rawOffset: buffer.readUInt32LE(header + 20) + }) + } + const fileOffset = (rva) => { + const section = sections.find( + (candidate) => + rva >= candidate.virtualAddress && rva < candidate.virtualAddress + candidate.size + ) + if (!section) { + throw new Error(`RVA 0x${rva.toString(16)} is outside every section`) + } + return rva - section.virtualAddress + section.rawOffset + } + const names = [] + for (let descriptor = fileOffset(importRva); ; descriptor += 20) { + const nameRva = buffer.readUInt32LE(descriptor + 12) + if (nameRva === 0) { + return names + } + const start = fileOffset(nameRva) + names.push(buffer.toString('latin1', start, buffer.indexOf(0, start))) + } +} + +// The Visual C++ runtime DLLs ship with the Redistributable, not with Windows. +const DYNAMIC_VC_RUNTIME_RE = /^(?:vcruntime|msvcp|msvcr)\d+(?:_\d+)?\.dll$/i + +export function findDynamicVcRuntimeImports(dllNames) { + return dllNames.filter((name) => DYNAMIC_VC_RUNTIME_RE.test(name)) +} diff --git a/config/scripts/windows-pe-imports.test.mjs b/config/scripts/windows-pe-imports.test.mjs new file mode 100644 index 00000000000..11cfbbfc78e --- /dev/null +++ b/config/scripts/windows-pe-imports.test.mjs @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { findDynamicVcRuntimeImports, readPeImportedDllNames } from './windows-pe-imports.mjs' + +// Smallest PE32+ image with one section holding an import directory for `dllNames`. +function peWithImports(dllNames) { + const peOffset = 0x40 + const optionalHeaderSize = 240 + const sectionHeader = peOffset + 24 + optionalHeaderSize + const rawOffset = 0x200 + const virtualAddress = 0x1000 + const descriptorsSize = (dllNames.length + 1) * 20 + const strings = dllNames.map((name) => Buffer.from(`${name}\0`, 'latin1')) + const sectionSize = descriptorsSize + strings.reduce((sum, item) => sum + item.length, 0) + const buffer = Buffer.alloc(rawOffset + sectionSize) + buffer.write('MZ', 0, 'latin1') + buffer.writeUInt32LE(peOffset, 0x3c) + buffer.write('PE\0\0', peOffset, 'latin1') + buffer.writeUInt16LE(0x8664, peOffset + 4) + buffer.writeUInt16LE(1, peOffset + 6) + buffer.writeUInt16LE(optionalHeaderSize, peOffset + 20) + buffer.writeUInt16LE(0x20b, peOffset + 24) + buffer.writeUInt32LE(virtualAddress, peOffset + 24 + 112 + 8) + buffer.writeUInt32LE(sectionSize, sectionHeader + 8) + buffer.writeUInt32LE(virtualAddress, sectionHeader + 12) + buffer.writeUInt32LE(sectionSize, sectionHeader + 16) + buffer.writeUInt32LE(rawOffset, sectionHeader + 20) + let stringRva = virtualAddress + descriptorsSize + strings.forEach((item, index) => { + buffer.writeUInt32LE(stringRva, rawOffset + index * 20 + 12) + item.copy(buffer, rawOffset + (stringRva - virtualAddress)) + stringRva += item.length + }) + return buffer +} + +describe('windows PE imports', () => { + it('reads every imported DLL name in order', () => { + const names = ['KERNEL32.dll', 'VCRUNTIME140.dll', 'api-ms-win-crt-runtime-l1-1-0.dll'] + expect(readPeImportedDllNames(peWithImports(names))).toEqual(names) + }) + + it('reads an image with no imports', () => { + expect(readPeImportedDllNames(peWithImports([]))).toEqual([]) + }) + + it('rejects a file that is not a PE image', () => { + const buffer = Buffer.alloc(0x80) + buffer.writeUInt32LE(0x40, 0x3c) + expect(() => readPeImportedDllNames(buffer)).toThrow('Not a PE image') + }) + + it('flags only the Visual C++ Redistributable DLLs', () => { + expect( + findDynamicVcRuntimeImports([ + 'KERNEL32.dll', + 'ntdll.dll', + 'VCRUNTIME140.dll', + 'vcruntime140_1.dll', + 'MSVCP140.dll', + 'msvcr120.dll', + 'api-ms-win-crt-heap-l1-1-0.dll', + 'ucrtbase.dll' + ]) + ).toEqual(['VCRUNTIME140.dll', 'vcruntime140_1.dll', 'MSVCP140.dll', 'msvcr120.dll']) + }) +}) diff --git a/docs/reference/agent-status-store.md b/docs/reference/agent-status-store.md index e60e683c738..5dae5fafafb 100644 --- a/docs/reference/agent-status-store.md +++ b/docs/reference/agent-status-store.md @@ -446,8 +446,8 @@ call it. `terminal wait --for tui-idle` is a reader too. Before STA-9100 hook state reached it only through the ` ready` titles the window writes, so a headless `orca serve` never saw it (#16095). Now an agent whose rule file says -`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) has its -fresh row read straight from the store, through the same +`profile.hooks: "authoritative"` (OpenCode, OpenCode 2, Pi, OMP) or +`"turn-end"` (Codex) has its fresh row read straight from the store, through the same `selectFreshExplicitAgentStatusRow` join prompt-receipt verification uses (`src/main/runtime/tui-idle-hook-lane.ts`): @@ -469,11 +469,14 @@ fresh row read straight from the store, through the same OSC 133 zones itself; - every other agent stays `identity-only`: Claude sends no event when an approval is denied or Esc stops a tool, so its row can sit at `waiting` or - `working` forever, and the rules keep deciding. Codex is identity-only too: - before its `Interrupt` hook an Esc mid-turn leaves the row `working`, and an - older TUI can hand its hooks to a newer shared app server, so no version - check tells which Codex posts it. Current Codex settles fast anyway, since - `Interrupt` drives its `Codex ready` title. + `working` forever, and the rules keep deciding; +- Codex is `turn-end`: only a `done` decides (it settles the wait), and a + `working` or permission row leaves the decision to the rules. Before its + `Interrupt` hook an Esc mid-turn can leave the row `working`, and an older + TUI can hand its hooks to a newer shared app server, so no version check + tells which Codex posts it. A `done` is a real turn end on every version, so + trusting only that one keeps the headless gain (no quiet window after the + turn) without letting a missing cancel hang the wait. The titles stay for display; remote clients read them. diff --git a/docs/reference/ci-demand-rollout.md b/docs/reference/ci-demand-rollout.md index c1be4219d6b..00dfdca4f5f 100644 --- a/docs/reference/ci-demand-rollout.md +++ b/docs/reference/ci-demand-rollout.md @@ -10,24 +10,34 @@ or a guaranteed forecast of savings. ## What runs now -| Work | Ordinary draft update | Ready PR / final checks | Main reference | -| -------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------- | --------------------------------------- | -| Static analysis and types | Immediately | Immediately | Existing workflows | -| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | -| Packages | After successful static analysis and types | Same | Existing release workflows | -| Headless Node persistence | Linux x64 for ordinary runtime changes; all six platforms for sensitive inputs | All six platforms when server inputs changed | Full nightly qualification at 11:30 UTC | -| Headless Node glibc/musl qualification | Sensitive inputs only, after persistence succeeds | Both architectures after persistence succeeds | Both architectures | -| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | +| Work | Ordinary draft update | Ready PR / final checks | Main reference | +| -------------------------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| Static analysis and types | Immediately | Immediately | Existing workflows | +| Unit suite | Full, with shadow selection evidence | Full | Existing daily Node 24/26 x86 suite | +| Packages | After successful static analysis and types | Same | Existing release workflows | +| Headless Node persistence | Deferred until ready | Linux x64 for ordinary runtime changes; explicit platform families or all six for sensitive inputs | All six platforms for relevant main pushes; full nightly qualification at 11:30 UTC | +| Headless Node glibc/musl qualification | Deferred until ready | Linux-specific or full qualification, after persistence succeeds | Both architectures for relevant main pushes and nightly qualification | +| E2E | Existing targeted routing | Existing targeted routing | One complete run at 17:00 UTC | -Headless-runtime-sensitive inputs include root/toolchain files, configuration, native code, -resources, platform-specific paths, persistence, SQLite, orcad, providers, -daemon, SSH, relay, and child-process code. Dependency discovery failure, a -missing event or an incomplete diff retains full qualification. An unrelated -change still skips these lanes through the existing dependency classifier. No native -artifact is shared across platforms or ABIs. The routine draft reduction is an -explicit coverage-placement change; it does not assert identical per-update -coverage. Every non-draft synchronize event and the ready-for-review event -restores full qualification. +Headless draft updates carry no verdict; readiness starts the checks. Relevant +ready PRs retain Linux x64 smoke coverage. Explicit Windows or macOS paths add both +architectures in that family, while Linux paths add Linux ARM and the glibc/musl +lanes. Root/toolchain inputs, native build inputs, shared execution/storage paths, +SSH, providers and relay changes retain every platform. Missing or incomplete +change evidence and failed import analysis also retain full qualification. +Unrelated changes skip through the dependency classifier. Relevant main pushes +qualify all six platforms and both Linux compatibility architectures; detection +uses the whole push before qualification can supersede an older relevant run. + +The detector checks known build inputs with the pinned Node toolchain first. +Other paths install dependencies for import analysis; an uncertain result never +skips qualification. This changes setup cost, not the qualification policy. + +Windows server prebuilds are cached separately by architecture and pinned build +inputs, including the runner image. Only successful qualification on main publishes +them. Consumers validate the payload and still run the pinned-Node load/spawn smoke; +a cache miss or invalid payload builds fresh. Nightly, manual and release builds +remain fresh. No native artifact is shared across platforms or ABIs. Expensive PR jobs wait for static/type success. This reduces fan-out for failed or rapidly superseded commits without sleeping on a runner. Successful isolated diff --git a/docs/reference/ci-runner-efficiency.md b/docs/reference/ci-runner-efficiency.md index 6c7f0d059cf..77b6479804b 100644 --- a/docs/reference/ci-runner-efficiency.md +++ b/docs/reference/ci-runner-efficiency.md @@ -679,8 +679,8 @@ coverage, and release behavior: Shard 4 spent 535 worker-seconds importing and 357 executing tests; a uniform per-file import estimate misses that cost. See [timing refresh](../../config/scripts/ci-shard-timings.md). - Seed Node 24 native modules, the pinned Git compatibility binary, and TypeScript - state on the default branch, hourly - and when dependency/toolchain inputs change. One ten-minute-bounded hosted job + state on the default branch when dependency/toolchain inputs change, with + scheduled recovery (originally hourly; now every six hours). One ten-minute-bounded hosted job reuses existing cache keys and skips typechecking an already-cached commit. New PRs can restore default-branch caches, while caches saved by another PR are inaccessible. The audit found 80 entries totaling 10.67 GiB, including @@ -1216,7 +1216,7 @@ These are single cold/warm observations, not paired medians or a measured whole-workflow saving. They demonstrate usable exact-key reuse after publication; future savings depend on cache availability and unchanged native inputs. The trial seeds belong to this PR's merge ref. Other PRs require a main-branch seed -after merging this new namespace; the existing main push and hourly warming +after merging this new namespace; the existing main-push and scheduled warming jobs provide that seed. ## Separate mobile install verification: retain the current policy @@ -1269,7 +1269,7 @@ This measures the three-file oracle cohort. Whole-shard timings include other test bodies, imports and transforms, so a whole-suite saving needs separate measurement. -## Cache warming: let hourly ticks wait for active work +## Cache warming: let scheduled ticks wait for active work The hourly warmer previously cancelled an active warmer, even when both used the same source. On October 2, the [merge-triggered run](https://github.com/stablyai/orca/actions/runs/36965832780) @@ -1289,6 +1289,27 @@ scheduled run or its repeated successful lanes, and pending replacement still applies regardless of the cancellation expression. The bounded 20-run sample contains this collision; it does not establish a recurring or whole-CI saving. +## Cache warming: six-hour recovery interval + +Scheduled warming now runs at 00:41, 06:41, 12:41 and 18:41 UTC instead of hourly. +Main pushes that change cache inputs still seed immediately, and manual dispatch +remains available. All five jobs, probes, keys and publication rules remain. +This removes 20 scheduled workflows and 100 scheduled job starts per day (83%). + +Four consecutive October 2 scheduled runs used the same source. The +[18:50 UTC run](https://github.com/stablyai/orca/actions/runs/37050194510) used 474 +aggregate runner-seconds across five jobs, including 242 seconds on Windows ARM. +That job restored exact package, verification and native caches; package-store +restore alone took about 70 seconds. Repeating that observed duration twenty +fewer times would avoid about 158 runner-minutes daily, but this one-run estimate +is not a billing forecast or measured post-rollout saving. + +The longer interval can delay background repair after eviction or runner-image +changes. Existing consumers retain cold-cache installation/build fallback, and +normal cache reads update last access. Storage was near the repository limit +when audited, so retention and unchanged hit rates are not guaranteed. Observe +misses before reducing the recovery frequency further. + ## Daemon shutdown fixture: remove build tools after compilation The fixture now removes compiler and Python build dependencies, plus npm and @@ -1392,3 +1413,94 @@ one CI failure does not establish a failure-rate reduction. The bounded 50-head main sample ending at 8ff6296 contained no root package metadata changes. Removing app-version metadata from the Windows server cache key would not improve reuse in that sample, so the key remains unchanged. + +## Windows ARM SSH: prepare the inbox capability during independent builds + +The ARM inbox lane starts guarded Windows capability preparation after the pure +provisioning self-test and waits for it before any private SSH server or host cell +runs. Dependency installation and the unchanged native artifacts can run during +that preparation. Preview and x64 lanes keep their existing serial provisioning; +the registered background step completes without mutation in those lanes. + +The preparation and the foreground provider use the same installer and isolation +guards. The receipt must match the source, run, attempt, runner, image and native +architecture. The foreground provider still reads the installed capability and +verifies every native binary and Microsoft signature. Account ownership, ACLs, +DefaultShell, private service identity, host cells and cleanup remain independent +checks. A background failure propagates through the unconditional native wait. + +Two full four-lane pairs used frozen source refs and the same dependency and +native-install policy. The [first baseline](https://github.com/stablyai/orca/actions/runs/36986929163) +ran before the [first candidate](https://github.com/stablyai/orca/actions/runs/36986970976); +the [second candidate](https://github.com/stablyai/orca/actions/runs/36991232037) +was dispatched before the [second baseline](https://github.com/stablyai/orca/actions/runs/36991234729). +Runner image versions matched within each platform in both pairs. + +| Active job, seconds | First baseline | First candidate | Second baseline | Second candidate | +| ------------------- | -------------: | --------------: | --------------: | ---------------: | +| ARM inbox | 2,403 | 1,644 | 2,353 | 1,667 | +| ARM preview | 1,002 | 935 | 886 | 872 | +| x64 inbox | 636 | 732 | 616 | 620 | +| x64 preview | 562 | 561 | 623 | 566 | + +The ARM inbox observations improved by 759 and 686 seconds. Baseline dependency +installation and artifact builds consumed 501 and 498 seconds before capability +installation could start. Candidate capability installation ran during that +work, but also took about 261 and 232 seconds less than the baseline. Candidate +dependency installation was slower, particularly in the second pair. These +observations support overlap on ARM; they do not establish a guaranteed 11–13 +minute saving, a reduction in queue time, or the cause of installer variability. +The x64 lane showed no repeatable gain, so it keeps serial preparation. + +All 16 actual Windows providers and 48 host-cell verdicts passed across the two +pairs. Receipts verify native machine identity, private service absence, owned +process exit, account removal and key removal. Loaded profile disposition remains +separate from those required cleanup checks. Hosted execution also verified the +native background/wait syntax; older actionlint versions do not recognize it. + +### Overlap the private profile observation budgets + +After service deletion and owned process exit, profile cleanup polls each owned +SID with its own full 30-second monotonic budget. Independent budgets now run +together. Every deletion follows a fresh targeted read; loaded profiles remain +for disposable VM destruction. Service identity, PID ownership, process exit, +account removal and key removal still fail the complete provider on error. + +The maintained diagnostics self-test executes the actual cleanup try/catch with +scoped Windows API and clock controls. Eight positive cases cover full windows, +late unload, reload, query overhead, mixed states and missing SIDs; ten specific +failure cases cover foreign profiles and the required cleanup gates. Disposable +shortened-deadline and stale-snapshot mutations fail those controls. A separate +mocked real-clock observation took 30.179 seconds for three loaded profiles, +compared with about 90 seconds for serial full budgets. This measures polling, +not an actual Windows provider or the entire job. + +The third profile no longer gains incidental extra time while earlier profiles +consume their budgets. A profile unloading at 45 seconds may therefore remain +where serial cleanup removed it. This uses the existing disposable-VM fallback; +it does not remove a loaded profile or relax mandatory account/key cleanup. +Hosted qualification of the combined workflow remains pending. + +## Coordinator mail tests: advance observation windows without removing them + +Six cases advance their original six 1,500 ms and ten 100 ms observation windows +with a scoped clock. Real filesystem, SQLite, journal, RPC and runtime work still +finishes asynchronously. The original journal-read gate and all counter and +operation assertions remain. Cancellation during delayed startup and the +Date-only age case retain real timers. Teardown stops the host and closes the +database before advancing the known 2,000 ms orphan repair, then asserts no fake +timers remain and restores the clock in `finally`. + +Two opposite-order local pairs passed the same 23 cases and unchanged source +hashes. Selected-case totals fell from 13.674 to 3.318 seconds and from 13.276 to +6.323 seconds. Whole-file test totals fell from 24.845 to 10.829 seconds and from +21.500 to 19.410 seconds. Process wall times were 41.488/37.810 seconds and +42.140/78.450 seconds; the reverse candidate spent 56.31 seconds importing under +unrelated local load. Overall wall-time savings remain inconclusive. + +Injected extra deliveries at 1,499 ms and 99 ms still fail the original assertions +in both clock modes. The latter candidate fails the unchanged journal-read gate +with the same extra provider start. A separate control confirms the orphan repair +actually executes against the closed database and leaves no fake timers. The +change retains all 121 original expectation sites and adds one teardown check; +it does not shorten the runtime's observation interval or claim a whole-PR gain. diff --git a/docs/site/content/docs/telemetry.mdx b/docs/site/content/docs/telemetry.mdx index 404c011d443..305304c951d 100644 --- a/docs/site/content/docs/telemetry.mdx +++ b/docs/site/content/docs/telemetry.mdx @@ -20,7 +20,7 @@ Alongside each event we include basic build and platform information: the Orca v The categories of behavior we observe: - **Lifecycle** — when the app opens. Used to estimate daily, weekly, and monthly active users. -- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. +- **Repos and workspaces** — when you add a repo or create a workspace. We record _how_ you did it (e.g. folder picker vs. clone URL; command palette vs. drag-and-drop), never the repo name, URL, path, branch name, or any free-form text. For a workspace create we also record how long it took and how that time split across its steps, whether a checkout Orca had prepared in advance was reused (and if so, what reset it needed, what prepared it, how long it took to prepare and how long it waited unused; if not, a fixed reason code), for a failed create which step it stopped in, plus coarse context: whether it came from the Orca window or from the CLI, phone or agent interface, whether Git ran locally, in WSL or over SSH, a bucketed worktree count, a bucketed count of the files the repo tracks, how many other creates and prepared-checkout jobs were running, and whether the repo has a post-checkout hook (never its contents). - **Agents** — which agents you use and their token counts. Never prompts or agent output. - **Agent errors** — a coarse error category and which agent kind was involved. We never see raw error messages or stack traces; per-incident detail stays in a local diagnostic trace file on your machine and only reaches Orca if you explicitly share a diagnostic bundle. - **Settings** — when you toggle one of a small whitelisted set of feature-flag or UX preferences. We record which preference changed and whether it's a boolean or an enum, never the raw value of any free-form setting. diff --git a/native/windows-cli-launcher/.cargo/config.toml b/native/windows-cli-launcher/.cargo/config.toml new file mode 100644 index 00000000000..7a86328d513 --- /dev/null +++ b/native/windows-cli-launcher/.cargo/config.toml @@ -0,0 +1,4 @@ +# Why: link the C runtime statically so orca.exe runs on Windows hosts without the +# Visual C++ Redistributable (VCRUNTIME140.dll is not part of Windows). +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/src/main/codex/codex-shared-server-pane.test.ts b/src/main/codex/codex-shared-server-pane.test.ts index ef05bf5f006..14d76f7a425 100644 --- a/src/main/codex/codex-shared-server-pane.test.ts +++ b/src/main/codex/codex-shared-server-pane.test.ts @@ -29,9 +29,9 @@ vi.mock('../pty/shell-startup-env', () => ({ })) import { - findPaneCodexCommandLine, + findPaneCodex, + findPaneCodexOnSharedServer, isCodexPaneOnOrcaMirrorHome, - isPaneCodexOnSharedServer, resolveCodexPaneHome } from './codex-shared-server-pane' @@ -46,16 +46,17 @@ beforeEach(() => { mocks.isShellStartupEnvProbeSupported.mockReturnValue(true) }) -describe('findPaneCodexCommandLine', () => { +describe('findPaneCodex', () => { it('takes the launcher line, which carries the argv, over its native child', () => { const rows = [ row(SHELL, 1, '-bash'), row(101, SHELL, 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon'), row(102, 101, '/usr/lib/node_modules/@openai/codex/vendor/codex --no-daemon') ] - expect(findPaneCodexCommandLine(rows, SHELL)).toBe( - 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon' - ) + expect(findPaneCodex(rows, SHELL)).toEqual({ + command: 'node /usr/lib/node_modules/@openai/codex/bin/codex.js --no-daemon', + shell: 'bash' + }) }) it('ignores the shared server a Windows Codex spawns as its own child', () => { @@ -65,12 +66,29 @@ describe('findPaneCodexCommandLine', () => { row(102, 101, '"C:\\h\\codex.exe" app-server --listen unix:// --managed-daemon'), row(103, 101, '"C:\\h\\codex.exe" app-server daemon pid-update-loop') ] - expect(findPaneCodexCommandLine(rows, SHELL)).toBe('C:\\npm\\codex.exe') + expect(findPaneCodex(rows, SHELL)).toEqual({ command: 'C:\\npm\\codex.exe', shell: 'cmd' }) + }) + + it.each([ + ['a login shell', row(SHELL, 1, '-zsh'), 'zsh'], + ['a shell path', row(SHELL, 1, '/opt/homebrew/bin/fish -l'), 'fish'], + [ + 'a Windows image name over its command line', + { ...row(SHELL, 1, ''), name: 'pwsh.exe' }, + 'pwsh' + ] + ])('names the shell Codex was typed into from %s', (_label, shell, name) => { + expect(findPaneCodex([shell, row(101, SHELL, 'codex')], SHELL)?.shell).toBe(name) + }) + + it('leaves fish without config unnamed, since it never loads the codex function', () => { + const fish = row(SHELL, 1, '/opt/homebrew/bin/fish -l -N') + expect(findPaneCodex([fish, row(101, SHELL, 'codex')], SHELL)?.shell).toBeNull() }) it('ignores Codex outside this pane and non-Codex children', () => { const rows = [row(SHELL, 1, '-zsh'), row(101, SHELL, 'vim'), row(201, 1, 'codex')] - expect(findPaneCodexCommandLine(rows, SHELL)).toBeNull() + expect(findPaneCodex(rows, SHELL)).toBeNull() }) }) @@ -148,7 +166,7 @@ describe('isCodexPaneOnOrcaMirrorHome', () => { }) }) -describe('isPaneCodexOnSharedServer', () => { +describe('findPaneCodexOnSharedServer', () => { beforeEach(() => { mocks.getCodexPaneAccount.mockReturnValue({ selectionKey: 'host', @@ -161,20 +179,23 @@ describe('isPaneCodexOnSharedServer', () => { mocks.readWindowsProcessTable.mockResolvedValue(rows) }) - it('is true for a typed codex while its home has a live server', async () => { - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(true) + it('finds a typed codex while its home has a live server', async () => { + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toEqual({ + command: 'codex', + shell: 'bash' + }) expect(mocks.probeCodexSharedServer).toHaveBeenCalledWith('/home/me/.codex') }) it.each(['absent', 'unknown'] as const)( - 'is false when the pane home server is %s', + 'finds none when the pane home server is %s', async (state) => { mocks.probeCodexSharedServer.mockResolvedValue(state) - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull() } ) - it('is false when Codex runs with --no-daemon, without probing', async () => { + it('finds none when Codex runs with --no-daemon, without probing', async () => { mocks.getProcessTableSnapshot.mockResolvedValue([ row(SHELL, 1, '-bash'), row(101, SHELL, 'codex --no-daemon') @@ -183,13 +204,13 @@ describe('isPaneCodexOnSharedServer', () => { row(SHELL, 1, 'cmd.exe'), row(101, SHELL, 'codex --no-daemon') ]) - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull() expect(mocks.probeCodexSharedServer).not.toHaveBeenCalled() }) - it('is false when the pane home cannot be named, without reading processes', async () => { + it('finds none when the pane home cannot be named, without reading processes', async () => { mocks.getCodexPaneAccount.mockReturnValue(null) - await expect(isPaneCodexOnSharedServer('pty', SHELL)).resolves.toBe(false) + await expect(findPaneCodexOnSharedServer('pty', SHELL)).resolves.toBeNull() expect(mocks.getProcessTableSnapshot).not.toHaveBeenCalled() expect(mocks.readWindowsProcessTable).not.toHaveBeenCalled() }) diff --git a/src/main/codex/codex-shared-server-pane.ts b/src/main/codex/codex-shared-server-pane.ts index 3f010995dbb..9cfd9ac18fb 100644 --- a/src/main/codex/codex-shared-server-pane.ts +++ b/src/main/codex/codex-shared-server-pane.ts @@ -1,3 +1,4 @@ +import { win32 as pathWin32 } from 'node:path' import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { codexCommandLineJoinsSharedServer } from '../../shared/codex-shared-server-command' import { @@ -8,23 +9,41 @@ import { import { getProcessTableSnapshot } from '../../shared/process-table-snapshot-reader' import { readWindowsProcessTable } from '../windows/windows-process-table' import { isShellStartupEnvProbeSupported } from '../pty/shell-startup-env' +import { fishArgsSkipConfig } from '../fish-xdg-data-dirs-handoff' import { getSystemCodexHomePath, resolveOrcaManagedCodexHomePath } from './codex-home-paths' import { getCodexPaneAccount } from './codex-pane-account-registry' import { probeCodexSharedServer } from './codex-shared-server-probe' -type CommandRow = ProcessIdentityRow & { command: string } +type CommandRow = ProcessIdentityRow & { command: string; name?: string } + +export type PaneCodexProcess = { + command: string + /** Lowercase name of the process that launched Codex, e.g. `zsh` or `pwsh`; null when absent. */ + shell: string | null +} + +// Why name first: Windows rows carry the image name, and their command line may be empty. +function executableName(row: CommandRow): string { + const executable = row.name || row.command.trim().split(/\s+/)[0] || '' + return pathWin32 + .basename(executable) + .replace(/^-/, '') + .replace(/\.exe$/i, '') + .toLowerCase() +} /** - * The outermost Codex under the pane's shell. Outermost because a launcher - * (`node …/codex.js`) carries the argv, and on Windows the shared server it - * starts is its own child. + * The outermost Codex under the pane's shell, and the shell it was typed into. + * Outermost because a launcher (`node …/codex.js`) carries the argv, and on + * Windows the shared server it starts is its own child. */ -export function findPaneCodexCommandLine( +export function findPaneCodex( rows: readonly CommandRow[], rootPid: number -): string | null { +): PaneCodexProcess | null { + const index = getProcessTableIndex(rows) let outermost: (CommandRow & { depth: number }) | null = null - for (const row of collectDescendantsFromIndex(getProcessTableIndex(rows), rootPid)) { + for (const row of collectDescendantsFromIndex(index, rootPid)) { if ( (!outermost || row.depth < outermost.depth) && recognizeAgentProcessFromCommandLine(row.command, { includeHeadlessOneShot: true })?.agent === @@ -33,7 +52,15 @@ export function findPaneCodexCommandLine( outermost = row } } - return outermost?.command ?? null + if (!outermost) { + return null + } + const parent = index.byPid.get(outermost.ppid) + const shell = parent ? executableName(parent) : null + // Why: fish without config never loads Orca's codex function, so it counts as unwrapped. + const unwrappedFish = + shell === 'fish' && fishArgsSkipConfig(parent?.command.trim().split(/\s+/).slice(1) ?? []) + return { command: outermost.command, shell: unwrappedFish ? null : shell } } /** @@ -70,18 +97,21 @@ export function isCodexPaneOnOrcaMirrorHome(ptyId: string): boolean { ) } -/** Whether the Codex running in this local pane is a client of Codex's shared server. */ -export async function isPaneCodexOnSharedServer(ptyId: string, rootPid: number): Promise { +/** This local pane's Codex when it is a client of Codex's shared server; otherwise null. */ +export async function findPaneCodexOnSharedServer( + ptyId: string, + rootPid: number +): Promise { const codexHome = resolveCodexPaneHome(ptyId) if (!codexHome) { - return false + return null } const rows: readonly CommandRow[] = process.platform === 'win32' ? await readWindowsProcessTable() : await getProcessTableSnapshot() - const commandLine = findPaneCodexCommandLine(rows, rootPid) - return ( - commandLine !== null && - codexCommandLineJoinsSharedServer(commandLine) && + const codex = findPaneCodex(rows, rootPid) + return codex && + codexCommandLineJoinsSharedServer(codex.command) && (await probeCodexSharedServer(codexHome)) === 'live' - ) + ? codex + : null } diff --git a/src/main/daemon/daemon-protocol-version.ts b/src/main/daemon/daemon-protocol-version.ts index 42f87382d62..7f9f911c647 100644 --- a/src/main/daemon/daemon-protocol-version.ts +++ b/src/main/daemon/daemon-protocol-version.ts @@ -1,6 +1,7 @@ // Why: daemons survive app updates, so wire behavior must be version-gated. // v39 gives plain fish panes Orca's codex function through XDG_DATA_DIRS; older owners stay attachable. export const PROTOCOL_VERSION = 39 +export const CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION = 39 // Why: older daemons reject `setColorQueryReplyColors` as an unknown request type. export const COLOR_QUERY_REPLY_COLORS_DAEMON_PROTOCOL_VERSION = 38 export const CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION = 37 diff --git a/src/main/daemon/daemon-provider-routing.test.ts b/src/main/daemon/daemon-provider-routing.test.ts new file mode 100644 index 00000000000..721f1e52c06 --- /dev/null +++ b/src/main/daemon/daemon-provider-routing.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from 'vitest' +import { getLegacyDaemonAdapters } from './daemon-provider-routing' +import { LocalPtyProvider } from '../providers/local-pty-provider' + +describe('getLegacyDaemonAdapters', () => { + it('lists none for the in-process fallback provider', () => { + expect(getLegacyDaemonAdapters(new LocalPtyProvider())).toEqual([]) + }) +}) diff --git a/src/main/daemon/daemon-provider-routing.ts b/src/main/daemon/daemon-provider-routing.ts index 0198bf2893c..4a2e648c37e 100644 --- a/src/main/daemon/daemon-provider-routing.ts +++ b/src/main/daemon/daemon-provider-routing.ts @@ -1,6 +1,7 @@ import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider' import type { DaemonPtyAdapter } from './daemon-pty-adapter' import { DaemonPtyRouter } from './daemon-pty-router' +import type { IPtyProvider } from '../providers/types' export type DaemonProvider = DaemonPtyRouter | DaemonPtyAdapter | DegradedDaemonPtyProvider @@ -11,7 +12,7 @@ export function getCurrentDaemonAdapter(provider: DaemonProvider): DaemonPtyAdap return provider } -export function getLegacyDaemonAdapters(provider: DaemonProvider): DaemonPtyAdapter[] { +export function getLegacyDaemonAdapters(provider: IPtyProvider): DaemonPtyAdapter[] { if (provider instanceof DaemonPtyRouter || provider instanceof DegradedDaemonPtyProvider) { return [...provider.getLegacyAdapters()] } diff --git a/src/main/daemon/serialize-grid-transcript-replay.test.ts b/src/main/daemon/serialize-grid-transcript-replay.test.ts index 28f0eed4459..b055aef804d 100644 --- a/src/main/daemon/serialize-grid-transcript-replay.test.ts +++ b/src/main/daemon/serialize-grid-transcript-replay.test.ts @@ -99,7 +99,12 @@ const KNOWN_PREEXISTING_I2_FAILURES: Record = { 'prime-agent-0-9-8-slash-menu': 34, 'prime-agent-0-9-8-tool-turn': 40, 'prime-agent-0-9-8-trace-question': 4, - 'prime-agent-0-9-8-turn-ended': 24 + 'prime-agent-0-9-8-turn-ended': 24, + // OMP 18.4.5 captures, serializer untouched: after a shrink the restored cursor sits one column + // short, as for Qoder. The unscrubbed captures diverge identically, so the scrub is not the cause. + 'omp-18-composer': 2, + 'omp-18-composer-narrow': 7, + 'omp-18-setup': 6 } // Exact resize checkpoints and full GridDiff hashes from base 6835b9b4e3ea, not this branch. diff --git a/src/main/fish-xdg-data-dirs-handoff.ts b/src/main/fish-xdg-data-dirs-handoff.ts index bec47294044..e5be3ce6ab8 100644 --- a/src/main/fish-xdg-data-dirs-handoff.ts +++ b/src/main/fish-xdg-data-dirs-handoff.ts @@ -22,7 +22,7 @@ export function getFishVendorConfSnippetPath(wrapperRoot: string): string { // Why: -N/--no-config (also abbreviated or in a flag cluster) skips vendor_conf.d, // so the snippet could never undo the env. Over-matching only costs the codex hook. -function fishArgsSkipConfig(fishArgs: readonly string[]): boolean { +export function fishArgsSkipConfig(fishArgs: readonly string[]): boolean { return fishArgs.some( (arg) => /^-[^-]*N/.test(arg) || (arg.length > 5 && '--no-config'.startsWith(arg)) ) diff --git a/src/main/git/command-runner/git-command-resolution.ts b/src/main/git/command-runner/git-command-resolution.ts index ffd022ad5ab..b63f6699dcb 100644 --- a/src/main/git/command-runner/git-command-resolution.ts +++ b/src/main/git/command-runner/git-command-resolution.ts @@ -14,7 +14,8 @@ import { usesHostGitForWslLinkedWorktree } from '../wsl-linked-worktree-git-rout import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution' import type { GitExecOptions } from './git-exec-options' -function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { +/** The WSL distro a Git command with this cwd runs in, or null when it runs on the host. */ +export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null { if (process.platform !== 'win32') { return null } diff --git a/src/main/git/create-event-repo-probe.test.ts b/src/main/git/create-event-repo-probe.test.ts new file mode 100644 index 00000000000..2bd993f0be1 --- /dev/null +++ b/src/main/git/create-event-repo-probe.test.ts @@ -0,0 +1,180 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { probeCreateEventRepoFacts } from './create-event-repo-probe' +import { createWorktreePreparationLockReason } from '../../shared/worktree/create-preparation' + +async function hookPresence(repoPath: string, platform: NodeJS.Platform): Promise { + return (await probeCreateEventRepoFacts(repoPath, platform)).postCheckoutHook +} + +describe('probeCreateEventRepoFacts', () => { + let repo: string + + beforeEach(async () => { + repo = await mkdtemp(path.join(tmpdir(), 'orca-hook-probe-')) + await mkdir(path.join(repo, '.git', 'hooks'), { recursive: true }) + await writeFile(path.join(repo, '.git', 'config'), '[core]\n\tbare = false\n') + }) + + afterEach(async () => { + await rm(repo, { recursive: true, force: true }) + }) + + async function writeHook(mode: number): Promise { + const hook = path.join(repo, '.git', 'hooks', 'post-checkout') + await writeFile(hook, '#!/bin/sh\nexit 0\n') + await chmod(hook, mode) + } + + it('reports absent when only sample hooks exist', async () => { + await writeFile(path.join(repo, '.git', 'hooks', 'post-checkout.sample'), '#!/bin/sh\n') + expect(await hookPresence(repo, 'darwin')).toBe('absent') + }) + + it('reports present for an executable hook', async () => { + await writeHook(0o755) + expect(await hookPresence(repo, 'linux')).toBe('present') + }) + + it('reports absent for a hook Git would skip as non-executable', async () => { + await writeHook(0o644) + expect(await hookPresence(repo, 'linux')).toBe('absent') + }) + + it('reports present for any hook file on Windows', async () => { + await writeHook(0o644) + expect(await hookPresence(repo, 'win32')).toBe('present') + }) + + it('reports a configured hooks path without following it', async () => { + await writeFile(path.join(repo, '.git', 'config'), '[core]\n\thooksPath = .husky/_\n') + await writeHook(0o755) + expect(await hookPresence(repo, 'linux')).toBe('custom_hooks_path') + }) + + it('reports neither fact when .git is a file it would need Git to resolve', async () => { + await rm(path.join(repo, '.git'), { recursive: true }) + await writeFile(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n') + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ postCheckoutHook: 'unknown' }) + }) + + async function registerWorktree(name: string, lockReason?: string): Promise { + const adminDir = path.join(repo, '.git', 'worktrees', name) + await mkdir(adminDir, { recursive: true }) + if (lockReason !== undefined) { + await writeFile(path.join(adminDir, 'locked'), `${lockReason}\n`) + } + } + + it('counts the main checkout plus the linked worktrees, leaving out Orca prepared checkouts', async () => { + await registerWorktree('feature-a') + await registerWorktree('feature-b', 'on a USB drive') + // This process's spare, another process's spare, and a crash leftover all carry the marker. + await registerWorktree('spare-here', createWorktreePreparationLockReason('here')) + await registerWorktree('spare-other', 'orca-create-preparation:v1:424242:other') + await registerWorktree('spare-crashed', 'orca-create-preparation:v1:7:gone') + await writeFile(path.join(repo, '.git', 'worktrees', 'stray-file'), '') + expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ worktreeCount: 3 }) + }) + + it('does not count a spare whose admin directory Git has not written yet', async () => { + await registerWorktree('feature-a') + // A spare that has only just started has no entry under .git/worktrees, so nothing to drop. + expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ worktreeCount: 2 }) + }) + + it('reports neither fact for a missing repo', async () => { + expect(await probeCreateEventRepoFacts(path.join(repo, 'missing'), 'linux')).toEqual({ + postCheckoutHook: 'unknown' + }) + }) + + async function writeIndex(signature: string, version: number, entries: number): Promise { + const header = Buffer.alloc(12) + header.write(signature, 0, 'latin1') + header.writeUInt32BE(version, 4) + header.writeUInt32BE(entries, 8) + await writeFile(path.join(repo, '.git', 'index'), Buffer.concat([header, Buffer.alloc(64)])) + } + + it.each([2, 4])( + 'reads the tracked-file count from a version %i index header', + async (version) => { + await writeIndex('DIRC', version, 123_456) + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ + postCheckoutHook: 'absent', + indexEntryCount: 123_456, + worktreeCount: 1 + }) + } + ) + + it('omits the count when the repo has no index yet', async () => { + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ + postCheckoutHook: 'absent', + worktreeCount: 1 + }) + }) + + it('omits the count for a file that is not an index', async () => { + await writeIndex('NOPE', 2, 10) + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it('omits the count for a truncated index', async () => { + await writeFile(path.join(repo, '.git', 'index'), Buffer.from('DIRC\0\0')) + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it('omits the count for a split index, whose entries live in a shared index', async () => { + await writeIndex('DIRC', 2, 3) + await writeFile(path.join(repo, '.git', 'sharedindex.0123abcd'), '') + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it('omits the count for a sparse index, which collapses directories', async () => { + await writeIndex('DIRC', 4, 3) + await writeFile(path.join(repo, '.git', 'config'), '[index]\n\tsparse = true\n') + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + }) + + it.each([ + '[index]\n\tsparse = true\n', + '[index]\n\tsparse = YES\n', + '[index]\n\tsparse\n', + '[core]\n\tsparseCheckout = 1\n' + ])( + 'omits the count when config.worktree turns sparse checkout on (%j)', + async (worktreeConfig) => { + await writeIndex('DIRC', 4, 7) + await writeFile(path.join(repo, '.git', 'config.worktree'), worktreeConfig) + expect(await probeCreateEventRepoFacts(repo, 'linux')).not.toHaveProperty('indexEntryCount') + } + ) + + it('keeps the count when sparse checkout is explicitly off', async () => { + await writeIndex('DIRC', 2, 11) + await writeFile( + path.join(repo, '.git', 'config'), + '[core]\n\tsparseCheckout = false\n\tsparseCheckoutCone = true\n[index]\n\tsparse = off\n' + ) + expect(await probeCreateEventRepoFacts(repo, 'linux')).toMatchObject({ indexEntryCount: 11 }) + }) + + it('reports a hooks path set in config.worktree', async () => { + await writeFile(path.join(repo, '.git', 'config.worktree'), '[core]\n\thooksPath = .githooks\n') + await writeHook(0o755) + expect(await hookPresence(repo, 'linux')).toBe('custom_hooks_path') + }) + + it('reports neither config-based fact when the config cannot be read', async () => { + await rm(path.join(repo, '.git', 'config')) + await writeIndex('DIRC', 2, 10) + expect(await probeCreateEventRepoFacts(repo, 'linux')).toEqual({ + postCheckoutHook: 'unknown', + worktreeCount: 1 + }) + }) +}) diff --git a/src/main/git/create-event-repo-probe.ts b/src/main/git/create-event-repo-probe.ts new file mode 100644 index 00000000000..6725c6948f5 --- /dev/null +++ b/src/main/git/create-event-repo-probe.ts @@ -0,0 +1,170 @@ +import { open, readFile, readdir, stat } from 'node:fs/promises' +import path from 'node:path' +import { getErrorCode } from './worktree-operation-options' +import { parseWorktreePreparationOwnerPid } from '../../shared/worktree/create-preparation' + +/** Existence only: the hook's content is never read. */ +export type PostCheckoutHookPresence = 'present' | 'absent' | 'custom_hooks_path' | 'unknown' + +/** What the create event says about the repo, read from its `.git` directory. */ +export type CreateEventRepoFacts = { + postCheckoutHook: PostCheckoutHookPresence + /** Entries in the index header, i.e. tracked files; absent when the count is missing or unreliable. */ + indexEntryCount?: number + /** The main checkout plus registered linked worktrees, Orca's prepared checkouts excluded. */ + worktreeCount?: number +} + +const PROBE_TIMEOUT_MS = 2_000 +// Shorter than the whole probe, so a slow per-worktree read cannot cost the other facts. +const WORKTREE_COUNT_TIMEOUT_MS = 1_500 + +/** A hung network or WSL mount must not hold the caller; every answer here is best-effort. */ +async function withTimeout(operation: Promise, ms: number, fallback: T): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), ms) + timer.unref?.() + }) + try { + return await Promise.race([operation, timeout]) + } finally { + clearTimeout(timer) + } +} + +/** + * Reads, from files only and never by spawning Git, whether `git worktree add` in this repo will + * run a repo-local post-checkout hook, how many files the repo tracks, and how many worktrees it has. + * + * A `core.hooksPath` in the repo's config is reported as such rather than followed, and global or + * included config is not read, so a hooks path set there reads as `absent`. A `.git` file (a repo + * registered from a linked worktree or submodule) needs Git to resolve, so it yields no facts. + */ +export function probeCreateEventRepoFacts( + repoPath: string, + platform: NodeJS.Platform = process.platform +): Promise { + return withTimeout(readFacts(repoPath, platform), PROBE_TIMEOUT_MS, { + postCheckoutHook: 'unknown' + }) +} + +async function readFacts( + repoPath: string, + platform: NodeJS.Platform +): Promise { + const gitDir = path.join(repoPath, '.git') + const isGitDirectory = await stat(gitDir).then( + (entry) => entry.isDirectory(), + () => false + ) + if (!isGitDirectory) { + return { postCheckoutHook: 'unknown' } + } + // `git sparse-checkout --sparse-index` and per-worktree settings write to config.worktree. + const [mainConfig, worktreeConfig] = await Promise.all([ + readFile(path.join(gitDir, 'config'), 'utf8').catch(() => null), + readFile(path.join(gitDir, 'config.worktree'), 'utf8').catch(() => '') + ]) + const config = mainConfig === null ? null : `${mainConfig}\n${worktreeConfig}` + const [postCheckoutHook, indexEntryCount, worktreeCount] = await Promise.all([ + readPostCheckoutHook(gitDir, config, platform), + readIndexEntryCount(gitDir, config), + withTimeout(readWorktreeCount(gitDir), WORKTREE_COUNT_TIMEOUT_MS, undefined) + ]) + return { + postCheckoutHook, + ...(indexEntryCount !== undefined ? { indexEntryCount } : {}), + ...(worktreeCount !== undefined ? { worktreeCount } : {}) + } +} + +/** + * The worktree count from Git's registry of linked worktrees, since the create itself no longer + * lists them. Like the listing, an entry whose lock reason names an Orca preparation is a prepared + * checkout, not the user's worktree, whichever process or crash left it. + */ +async function readWorktreeCount(gitDir: string): Promise { + const registry = path.join(gitDir, 'worktrees') + try { + const entries = await readdir(registry, { withFileTypes: true }) + const preparations = await Promise.all( + entries + .filter((entry) => entry.isDirectory()) + .map((entry) => + readFile(path.join(registry, entry.name, 'locked'), 'utf8').then( + (reason) => parseWorktreePreparationOwnerPid(reason.trim()) !== null, + () => false + ) + ) + ) + return preparations.filter((isPreparation) => !isPreparation).length + 1 + } catch (error) { + return getErrorCode(error) === 'ENOENT' ? 1 : undefined + } +} + +/** `core.sparseCheckout` or `index.sparse` set to anything Git reads as true, bare key included. */ +const SPARSE_CHECKOUT_ON = /^\s*sparse(checkout)?\s*(=(?!\s*(false|no|off|0)\s*$).*)?$/im + +/** + * The entry count from the index header (`DIRC`, version, big-endian count), which is the same in + * every index version. A split index keeps entries elsewhere and a sparse index collapses them into + * directories, so with either possible (any sparse checkout) the count is left out. + */ +async function readIndexEntryCount( + gitDir: string, + config: string | null +): Promise { + if (config === null || SPARSE_CHECKOUT_ON.test(config)) { + return undefined + } + try { + const entries = await readdir(gitDir) + if (entries.some((name) => name.startsWith('sharedindex.'))) { + return undefined + } + const index = await open(path.join(gitDir, 'index'), 'r') + try { + const header = Buffer.alloc(12) + const { bytesRead } = await index.read(header, 0, 12, 0) + if (bytesRead < 12 || header.toString('latin1', 0, 4) !== 'DIRC') { + return undefined + } + return header.readUInt32BE(8) + } finally { + await index.close() + } + } catch { + return undefined + } +} + +async function readPostCheckoutHook( + gitDir: string, + config: string | null, + platform: NodeJS.Platform +): Promise { + if (config === null) { + return 'unknown' + } + try { + if (/^\s*hookspath\s*=/im.test(config)) { + return 'custom_hooks_path' + } + const hook = await stat(path.join(gitDir, 'hooks', 'post-checkout')).catch((error) => { + if (getErrorCode(error) === 'ENOENT') { + return null + } + throw error + }) + if (!hook || !hook.isFile()) { + return 'absent' + } + // Git skips a hook without an execute bit, except on Windows where it has none to check. + return platform === 'win32' || (hook.mode & 0o111) !== 0 ? 'present' : 'absent' + } catch { + return 'unknown' + } +} diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts index cb51e1ca8ae..2f18727b9e8 100644 --- a/src/main/git/worktree-create-preparation.ts +++ b/src/main/git/worktree-create-preparation.ts @@ -125,6 +125,11 @@ export async function unlockPreparedWorktree( } } +export type FinalizedPreparedWorktree = AddWorktreeResult & { + /** The prepared checkout was not already at the requested commit, so it was reset onto it. */ + preparedHeadReset: boolean +} + export async function finalizePreparedWorktree( repoPath: string, preparedPath: string, @@ -134,7 +139,7 @@ export async function finalizePreparedWorktree( refreshLocalBaseRef = false, options: AddWorktreeOptions = {}, expectedLockReason: string -): Promise { +): Promise { const finalizeGitOptions: AddWorktreeOptions = { ...options, timeout: options.timeout ?? resolveWorktreeAddTimeoutMs() @@ -181,8 +186,8 @@ export async function finalizePreparedWorktree( await baseContext.pendingLocalBaseRefRefresh throw preparedResult.reason } - const preparedHeadOutput = preparedResult.value.stdout - if (preparedHeadOutput.trim() !== targetHead) { + const preparedHeadReset = preparedResult.value.stdout.trim() !== targetHead + if (preparedHeadReset) { await verifyOwnership() await gitExecFileAsync( [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], @@ -255,6 +260,7 @@ export async function finalizePreparedWorktree( // Why: the refresh overlapped the finalize above; it has no bearing on the checkout's content. const localBaseRefRefresh = await baseContext.pendingLocalBaseRefRefresh return { + preparedHeadReset, ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(baseContext.localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion: baseContext.localBaseRefUpdateSuggestion } diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts new file mode 100644 index 00000000000..f0fad64322f --- /dev/null +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.test.ts @@ -0,0 +1,57 @@ +import { resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../../persistence' +import type { Repo } from '../../../shared/repo-types' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), gitOptions: vi.fn() })) +vi.mock('../../repo-worktrees', () => ({ listRepoWorktreeGraph: mocks.graph })) +vi.mock('../../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.gitOptions +})) +vi.mock('../registered-worktree-roots-cache', () => ({ + resolveRegisteredWorktreePath: async (path: string) => path +})) +vi.mock('../filesystem-auth', () => ({ resolveAuthorizedPath: vi.fn() })) +vi.mock('../../providers/ssh-git-dispatch', () => ({ getSshGitProvider: vi.fn() })) +import { getRepoForSourceControlAi } from './filesystem-source-control-ai-targets' + +const repo: Repo = { + id: 'wsl-drive-repo', + path: resolve('/wsl-drive/repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} +const worktree = resolve('/wsl-drive/workspaces/feature-extra') + +function fixture(): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the ownership check reads only these store methods. + return { getRepo: () => repo, getAllWorktreeMeta: () => ({}) } as unknown as Store +} + +beforeEach(() => { + vi.resetAllMocks() + mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' }) + // Only WSL Git names the checkout the way the renderer does; host Git reads its `/mnt/c` metadata. + mocks.graph.mockImplementation(async (_repo: Repo, options?: { wslDistro?: string }) => [ + { path: options?.wslDistro ? worktree : resolve('/mnt/c/wsl-drive/workspaces/feature-extra') } + ]) +}) + +describe('source control AI repo ownership for a detected worktree', () => { + it('lists through the project runtime, so a WSL-runtime drive worktree keeps its repo', async () => { + await expect( + getRepoForSourceControlAi(fixture(), { repoId: repo.id, worktreePath: worktree }) + ).resolves.toBe(repo) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: 'Ubuntu-24.04' }) + }) + + it('does not claim a worktree the repo listing does not name', async () => { + await expect( + getRepoForSourceControlAi(fixture(), { + repoId: repo.id, + worktreePath: resolve('/wsl-drive/elsewhere') + }) + ).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts index 2f9e5e92c09..eaa3f245dd9 100644 --- a/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts +++ b/src/main/ipc/filesystem/filesystem-source-control-ai-targets.ts @@ -1,6 +1,9 @@ import type { Repo } from '../../../shared/repo-types' import type { Store } from '../../persistence' -import type { LocalProjectWorktreeGitOptions } from '../../project-runtime-git-options' +import { + getLocalProjectWorktreeGitOptions, + type LocalProjectWorktreeGitOptions +} from '../../project-runtime-git-options' import type { CommitMessageAgentRuntimeTarget } from '../../text-generation/commit-message-agent-environment' import type { CommitMessageGenerationTarget } from '../../text-generation/commit-message-text-generation' import { resolve } from 'node:path' @@ -77,7 +80,11 @@ async function localRepoOwnsWorktree( return true } try { - const worktrees = await listRepoWorktreeGraph(repo) + // Why the project runtime: WSL Git names a drive worktree differently than host Git does. + const worktrees = await listRepoWorktreeGraph( + repo, + getLocalProjectWorktreeGitOptions(store, repo) + ) return worktrees.some((worktree) => candidatePaths.has(comparableLocalPath(worktree.path))) } catch { return false diff --git a/src/main/ipc/parcel-watcher-event-delivery.test.ts b/src/main/ipc/parcel-watcher-event-delivery.test.ts index 66c6232a63f..a7efb5b299c 100644 --- a/src/main/ipc/parcel-watcher-event-delivery.test.ts +++ b/src/main/ipc/parcel-watcher-event-delivery.test.ts @@ -5,7 +5,10 @@ const { statMock } = vi.hoisted(() => ({ statMock: vi.fn() })) vi.mock('node:fs/promises', () => ({ stat: statMock })) -import { prepareWatcherProcessEvents } from './parcel-watcher-event-delivery' +import { + createWatcherProcessEventDeliveryQueue, + prepareWatcherProcessEvents +} from './parcel-watcher-event-delivery' describe('prepareWatcherProcessEvents', () => { beforeEach(() => { @@ -42,3 +45,63 @@ describe('prepareWatcherProcessEvents', () => { expect(maxActiveStats).toBe(8) }) }) + +describe('watcher event queue flush', () => { + it('waits for active delivery and the bounded pending overflow', async () => { + const firstDelivery = Promise.withResolvers() + const deliver = vi + .fn() + .mockImplementationOnce(() => firstDelivery.promise) + .mockResolvedValue(undefined) + const queue = createWatcherProcessEventDeliveryQueue({ maxEventsPerBatch: 1 }, deliver, vi.fn()) + queue.enqueue([{ type: 'update', path: '/repo/first' }]) + await vi.waitFor(() => expect(deliver).toHaveBeenCalledTimes(1)) + queue.enqueue([ + { type: 'delete', path: '/repo/child' }, + { type: 'delete', path: '/repo' } + ]) + let flushed = false + const pending = queue.flush().then(() => { + flushed = true + }) + await Promise.resolve() + expect(flushed).toBe(false) + firstDelivery.resolve() + await pending + expect(deliver.mock.calls.map(([events]) => events)).toEqual([ + [{ type: 'update', path: '/repo/first' }], + null + ]) + await expect(queue.flush()).resolves.toBeUndefined() + queue.close() + }) + + it('settles all flush callers when closed while delivery is blocked', async () => { + const blocked = Promise.withResolvers() + const queue = createWatcherProcessEventDeliveryQueue({}, () => blocked.promise, vi.fn()) + queue.enqueue([{ type: 'update', path: '/repo/file' }]) + const first = queue.flush() + const second = queue.flush() + expect(second).toBe(first) + queue.close() + await expect(first).resolves.toBeUndefined() + await expect(queue.flush()).resolves.toBeUndefined() + blocked.resolve() + }) + + it('settles flush after reporting a delivery error', async () => { + const onError = vi.fn() + const failure = new Error('delivery failed') + const queue = createWatcherProcessEventDeliveryQueue( + {}, + async () => { + throw failure + }, + onError + ) + queue.enqueue([{ type: 'update', path: '/repo/file' }]) + await queue.flush() + expect(onError).toHaveBeenCalledWith(failure) + queue.close() + }) +}) diff --git a/src/main/ipc/parcel-watcher-event-delivery.ts b/src/main/ipc/parcel-watcher-event-delivery.ts index ed208b964f8..c190e2ee045 100644 --- a/src/main/ipc/parcel-watcher-event-delivery.ts +++ b/src/main/ipc/parcel-watcher-event-delivery.ts @@ -13,6 +13,7 @@ const directoryStatSlots = new PrioritySemaphore(DIRECTORY_STAT_CONCURRENCY) export type WatcherProcessEventDeliveryQueue = { enqueue(events: readonly ParcelWatcherEvent[]): void + flush(): Promise close(): void } @@ -75,6 +76,12 @@ export function createWatcherProcessEventDeliveryQueue( let draining = false let pendingOverflow = false let pendingEvents: ParcelWatcherEvent[] = [] + let flushCompletion: ReturnType> | undefined + + const finishFlush = (): void => { + flushCompletion?.resolve() + flushCompletion = undefined + } const drain = async (): Promise => { if (!active || draining) { @@ -104,6 +111,8 @@ export function createWatcherProcessEventDeliveryQueue( draining = false if (active && (pendingOverflow || pendingEvents.length > 0)) { void drain() + } else { + finishFlush() } } } @@ -123,11 +132,19 @@ export function createWatcherProcessEventDeliveryQueue( } void drain() }, + flush(): Promise { + if (!active || !draining) { + return Promise.resolve() + } + flushCompletion ??= Promise.withResolvers() + return flushCompletion.promise + }, close(): void { active = false controller.abort() pendingEvents = [] pendingOverflow = false + finishFlush() } } } diff --git a/src/main/ipc/parcel-watcher-host-subscriptions.ts b/src/main/ipc/parcel-watcher-host-subscriptions.ts index 3dad4dee73c..6394486497e 100644 --- a/src/main/ipc/parcel-watcher-host-subscriptions.ts +++ b/src/main/ipc/parcel-watcher-host-subscriptions.ts @@ -16,6 +16,11 @@ export type PendingWatcherUnsubscribe = (error?: Error) => void export const WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS = RUNTIME_FILE_WATCH_CANCEL_TIMEOUT_MS const terminalTeardownFailures = new WeakMap() +const pendingRecordUnsubscribes = new WeakMap>() +const hostSubscriptions = new WeakMap< + WatcherProcessSubscriptionRecord, + WatcherProcessSubscription +>() export function handleWatcherHostMessage( message: Exclude< @@ -138,49 +143,85 @@ export function createHostWatcherSubscription({ terminateUnavailableChild, sendToChild }: CreateHostWatcherSubscriptionOptions): WatcherProcessSubscription { - return { + const existing = hostSubscriptions.get(record) + if (existing) { + return existing + } + const unsubscribe = (): Promise => { + const terminalFailure = terminalTeardownFailures.get(record) + if (terminalFailure) { + return Promise.reject(terminalFailure) + } + const termination = getTerminationPromise() + if (!records.delete(record.id)) { + return termination ?? Promise.resolve() + } + resetPendingSubscribeAttempt(record) + if (termination) { + return termination + } + if (records.size === 0) { + return killWatcherChildIfIdle() + } + const child = getChild() + if (!child?.connected) { + return terminateUnavailableChild(child) + } + return new Promise((resolve, reject) => { + const onTimeout = (): void => { + if (pendingUnsubscribes.get(record.id) !== settle) { + return + } + // Why: canary setup itself can fail, so native teardown needs an + // independent host deadline that exits the child and releases handles. + void terminateUnavailableChild(child).catch(() => undefined) + } + const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) + timer.unref?.() + const settle: PendingWatcherUnsubscribe = (error) => { + clearTimeout(timer) + if (error) { + reject(error) + } else { + resolve() + } + } + pendingUnsubscribes.set(record.id, settle) + sendToChild(child, { op: 'unsubscribe', id: record.id }) + }) + } + const subscription: WatcherProcessSubscription = { unsubscribe: (): Promise => { - const terminalFailure = terminalTeardownFailures.get(record) - if (terminalFailure) { - return Promise.reject(terminalFailure) + const pending = pendingRecordUnsubscribes.get(record) + if (pending) { + return pending } - const termination = getTerminationPromise() - if (!records.delete(record.id)) { - return termination ?? Promise.resolve() - } - resetPendingSubscribeAttempt(record) - if (termination) { - return termination - } - if (records.size === 0) { - return killWatcherChildIfIdle() - } - const child = getChild() - if (!child?.connected) { - return terminateUnavailableChild(child) - } - return new Promise((resolve, reject) => { - const onTimeout = (): void => { - if (pendingUnsubscribes.get(record.id) !== settle) { - return - } - // Why: canary setup itself can fail, so native teardown needs an - // independent host deadline that exits the child and releases handles. - void terminateUnavailableChild(child).catch(() => undefined) + const closing = unsubscribe() + pendingRecordUnsubscribes.set(record, closing) + const clearPending = (): void => { + if (pendingRecordUnsubscribes.get(record) === closing) { + pendingRecordUnsubscribes.delete(record) } - const timer = setTimeout(onTimeout, WATCHER_PROCESS_UNSUBSCRIBE_TIMEOUT_MS) - timer.unref?.() - const settle: PendingWatcherUnsubscribe = (error) => { - clearTimeout(timer) - if (error) { - reject(error) - } else { - resolve() - } - } - pendingUnsubscribes.set(record.id, settle) - sendToChild(child, { op: 'unsubscribe', id: record.id }) - }) + } + void closing.then(clearPending, clearPending) + return closing } } + hostSubscriptions.set(record, subscription) + return subscription +} + +export async function retireHostWatcherSubscription( + record: WatcherProcessSubscriptionRecord, + error: Error, + shouldReport: () => boolean +): Promise { + try { + await hostSubscriptions.get(record)?.unsubscribe() + } catch (teardownError) { + error = teardownError instanceof Error ? teardownError : new Error(String(teardownError)) + } + if (shouldReport()) { + reportWatcherTerminalError(record, error) + } } diff --git a/src/main/ipc/parcel-watcher-native-error-retirement.test.ts b/src/main/ipc/parcel-watcher-native-error-retirement.test.ts new file mode 100644 index 00000000000..eb75dc9a8ef --- /dev/null +++ b/src/main/ipc/parcel-watcher-native-error-retirement.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + acknowledgeWatcherSubscribe, + currentWatcherChild, + FakeWatcherChild, + trackPromiseSettlement +} from './parcel-watcher-process-test-child' + +const { forkMock } = vi.hoisted(() => ({ forkMock: vi.fn() })) +vi.mock('node:child_process', () => ({ fork: forkMock })) +vi.mock('node:fs', () => ({ + existsSync: () => true, + mkdtempSync: () => '/tmp/orca-watcher-retirement-test', + rmSync: vi.fn() +})) + +import { WatcherProcessSupervisor } from './parcel-watcher-process-supervisor' +import { WATCHER_PROCESS_EXIT_DEADLINE_MS } from './parcel-watcher-child-termination' + +describe('native watcher error retirement', () => { + let supervisor: WatcherProcessSupervisor + + beforeEach(() => { + forkMock.mockReset().mockImplementation(() => new FakeWatcherChild()) + supervisor = new WatcherProcessSupervisor({ useInProcessVitestFallback: false }) + }) + + afterEach(() => supervisor.dispose()) + + it('closes only the failed handle before recovery and joins concurrent unsubscribe', async () => { + const callback = vi.fn() + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', callback, {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + const keepAlive = supervisor.subscribe('/other', vi.fn(), {}) + const otherId = acknowledgeWatcherSubscribe(child) + await keepAlive + + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + expect(child.sent.at(-1)).toEqual({ op: 'unsubscribe', id }) + expect(onTerminalError).not.toHaveBeenCalled() + expect(callback).not.toHaveBeenCalled() + const closing = subscription.unsubscribe() + const closed = trackPromiseSettlement(closing) + child.emit('message', { op: 'watch-error', id, message: 'duplicate error' }) + child.emit('message', { op: 'events', id, events: [{ type: 'update', path: '/repo/stale' }] }) + await Promise.resolve() + expect(closed()).toBe(false) + expect(child.sent.filter((message) => message.op === 'unsubscribe')).toHaveLength(1) + + child.emit('message', { op: 'unsubscribed', id }) + await closing + await vi.waitFor(() => expect(onTerminalError).toHaveBeenCalledTimes(1)) + expect(onTerminalError).toHaveBeenCalledWith( + expect.objectContaining({ message: 'native watcher stopped' }) + ) + expect(callback).not.toHaveBeenCalled() + expect(child.kill).not.toHaveBeenCalled() + + child.connected = false + child.emit('exit', 1, null) + const replacement = currentWatcherChild(forkMock) + expect(replacement.sent).toEqual([ + expect.objectContaining({ op: 'subscribe', id: otherId, dir: '/other' }) + ]) + }) + + it('waits for physical exit before reporting the last failed subscription', async () => { + const callback = vi.fn() + const pending = supervisor.subscribe('/repo', callback, {}) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + + child.emit('message', { op: 'watch-error', id, message: 'root removed' }) + expect(child.kill).toHaveBeenCalledTimes(1) + expect(callback).not.toHaveBeenCalled() + const closing = subscription.unsubscribe() + const closed = trackPromiseSettlement(closing) + await Promise.resolve() + expect(closed()).toBe(false) + child.emit('exit', 0, null) + await closing + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)) + expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'root removed' }), []) + expect(forkMock).toHaveBeenCalledTimes(1) + }) + + it('suppresses delayed recovery after supervisor disposal', async () => { + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + await pending + const keepAlive = supervisor.subscribe('/other', vi.fn(), {}) + acknowledgeWatcherSubscribe(child) + await keepAlive + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + supervisor.dispose() + child.emit('message', { op: 'unsubscribed', id }) + await Promise.resolve() + await Promise.resolve() + expect(onTerminalError).not.toHaveBeenCalled() + }) + + it('fences replacement behind physical exit when termination misses its deadline', async () => { + vi.useFakeTimers() + try { + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + + expect(onTerminalError).toHaveBeenCalledTimes(1) + const error = onTerminalError.mock.calls[0]?.[0] + expect(error).toMatchObject({ scope: 'supervisor', physicalExit: expect.any(Promise) }) + let exited = false + void error.physicalExit.then(() => { + exited = true + }) + await expect(subscription.unsubscribe()).rejects.toBe(error) + expect(exited).toBe(false) + child.emit('exit', 0, null) + await error.physicalExit + expect(exited).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('reports the removed owner when shared-child teardown misses its exit deadline', async () => { + vi.useFakeTimers() + try { + const onTerminalError = vi.fn() + const pending = supervisor.subscribe('/repo', vi.fn(), {}, { onTerminalError }) + const child = currentWatcherChild(forkMock) + const id = acknowledgeWatcherSubscribe(child) + const subscription = await pending + const otherTerminalError = vi.fn() + const other = supervisor.subscribe( + '/other', + vi.fn(), + {}, + { onTerminalError: otherTerminalError } + ) + acknowledgeWatcherSubscribe(child) + await other + child.emit('message', { op: 'watch-error', id, message: 'native watcher stopped' }) + child.emit('message', { op: 'unsubscribe-failed', id, message: 'native teardown failed' }) + await vi.advanceTimersByTimeAsync(WATCHER_PROCESS_EXIT_DEADLINE_MS) + + expect(onTerminalError).toHaveBeenCalledTimes(1) + expect(otherTerminalError).toHaveBeenCalledTimes(1) + const error = onTerminalError.mock.calls[0]?.[0] + expect(error).toMatchObject({ scope: 'supervisor', physicalExit: expect.any(Promise) }) + await expect(subscription.unsubscribe()).rejects.toBe(error) + child.emit('exit', 0, null) + await error.physicalExit + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/ipc/parcel-watcher-process-entry.test.ts b/src/main/ipc/parcel-watcher-process-entry.test.ts index 513775acb29..ccc76293af7 100644 --- a/src/main/ipc/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/parcel-watcher-process-entry.test.ts @@ -516,6 +516,92 @@ describe('parcel watcher process canary', () => { ) }) + it.each(['/repo', '/repo/Café'])( + 'invalidates only the deleted root %s after delivering its batch', + async (dir) => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe: vi.fn() } + }) + const sendMock = vi.fn() + process.send = sendMock + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { op: 'subscribe', id: 1, dir, opts: {} }) + await vi.advanceTimersByTimeAsync(0) + sendMock.mockClear() + + callback?.(null, [{ type: 'delete', path: `${dir}/nested` }]) + callback?.(null, [{ type: 'delete', path: `${dir}-other` }]) + await vi.advanceTimersByTimeAsync(0) + expect(sendMock.mock.calls.some(([message]) => message.op === 'watch-error')).toBe(false) + sendMock.mockClear() + + callback?.(null, [{ type: 'delete', path: dir }]) + callback?.(null, [{ type: 'update', path: `${dir}/late.txt` }]) + callback?.(null, [{ type: 'delete', path: dir }]) + await vi.advanceTimersByTimeAsync(0) + + expect(sendMock.mock.calls.map(([message]) => message)).toEqual([ + { op: 'events', id: 1, events: [{ type: 'delete', path: dir }] }, + { op: 'watch-error', id: 1, message: expect.stringContaining('root') } + ]) + } + ) + + it('invalidates an overflowing root-delete batch after IPC backpressure clears', async () => { + let callback: + | ((err: Error | null, events: { type: string; path: string }[]) => void) + | undefined + subscribeMock + .mockResolvedValueOnce({ unsubscribe: vi.fn() }) + .mockImplementationOnce(async (_dir, nextCallback) => { + callback = nextCallback + return { unsubscribe: vi.fn() } + }) + let releaseEvent: (() => void) | undefined + const sendMock = vi.fn((message, onSent) => { + if (message.op === 'events') { + releaseEvent = onSent + return false + } + return true + }) + process.send = sendMock + await import('./parcel-watcher-process-entry') + await vi.advanceTimersByTimeAsync(0) + process.emit('message', { + op: 'subscribe', + id: 1, + dir: '/repo', + opts: {}, + delivery: { maxEventsPerBatch: 1 } + }) + await vi.advanceTimersByTimeAsync(0) + sendMock.mockClear() + + callback?.(null, [{ type: 'update', path: '/repo/first.txt' }]) + await vi.advanceTimersByTimeAsync(0) + callback?.(null, [ + { type: 'delete', path: '/repo/child.txt' }, + { type: 'delete', path: '/repo' } + ]) + await vi.advanceTimersByTimeAsync(0) + expect(sendMock.mock.calls.map(([message]) => message.op)).toEqual(['events']) + releaseEvent?.() + await vi.advanceTimersByTimeAsync(0) + expect(sendMock.mock.calls.map(([message]) => message.op)).toEqual([ + 'events', + 'overflow', + 'watch-error' + ]) + }) + it('bounds pending event batches while child IPC reports backpressure', async () => { let callback: | ((err: Error | null, events: { type: string; path: string }[]) => void) diff --git a/src/main/ipc/parcel-watcher-process-entry.ts b/src/main/ipc/parcel-watcher-process-entry.ts index d2328821ade..9eb929fe2d6 100644 --- a/src/main/ipc/parcel-watcher-process-entry.ts +++ b/src/main/ipc/parcel-watcher-process-entry.ts @@ -180,6 +180,7 @@ function main(): void { opts: WatcherProcessSubscribeOptions, delivery: WatcherProcessDeliveryOptions | undefined ): Promise => { + let rootDeleted = false const eventDelivery = createWatcherProcessEventDeliveryQueue( delivery, async (events) => { @@ -216,11 +217,23 @@ function main(): void { return await watcher.subscribe( dir, (err, events) => { + if (rootDeleted) { + return + } if (err) { send({ op: 'watch-error', id, message: errorMessage(err) }) return } + rootDeleted = events.some((event) => event.type === 'delete' && event.path === dir) eventDelivery.enqueue(events) + if (rootDeleted) { + // Native backends stop deleted roots without reporting an error. + void eventDelivery + .flush() + .then(() => + send({ op: 'watch-error', id, message: 'File watcher root was deleted' }) + ) + } }, opts as ParcelWatcher.Options ) diff --git a/src/main/ipc/parcel-watcher-process-supervisor.ts b/src/main/ipc/parcel-watcher-process-supervisor.ts index 926ff036466..617749f04ed 100644 --- a/src/main/ipc/parcel-watcher-process-supervisor.ts +++ b/src/main/ipc/parcel-watcher-process-supervisor.ts @@ -44,7 +44,7 @@ export class WatcherProcessSupervisor { private child: ChildProcess | null = null private nextSubscriptionId = 1 private readonly crashFuse = new WatcherProcessCrashFuse() - private shutdownRequested = false + private shutdown = { requested: false, disposalRevision: 0 } private canaryDir: string | null = null private terminatingChild: ChildProcess | null = null private readonly terminationQueue = new termination.WatcherTerminationQueue() @@ -72,7 +72,7 @@ export class WatcherProcessSupervisor { callback, opts, hooks, - shutdownRequested: this.shutdownRequested, + shutdownRequested: this.shutdown.requested, entryPath: this.options.entryPath ?? getWatcherProcessEntryPath(), useInProcessVitestFallback: this.options.useInProcessVitestFallback ?? true, allocateId: () => this.nextSubscriptionId++, @@ -93,7 +93,8 @@ export class WatcherProcessSupervisor { } dispose(): void { - this.shutdownRequested = true + this.shutdown.requested = true + this.shutdown.disposalRevision++ this.capacityWait.dispose() const proc = this.child this.child = null @@ -109,7 +110,7 @@ export class WatcherProcessSupervisor { resetForTest(): void { this.dispose() this.ownedChildren = new WatcherOwnedChildren() - this.shutdownRequested = false + this.shutdown.requested = false this.terminatingChild = null this.terminationQueue.resetForTest() this.crashFuse.reset() @@ -121,7 +122,7 @@ export class WatcherProcessSupervisor { private ensureWatcherProcess( entryPath = this.options.entryPath ?? getWatcherProcessEntryPath() ): ChildProcess | null { - if (this.shutdownRequested || this.terminatingChild) { + if (this.shutdown.requested || this.terminatingChild) { return null } if (this.child?.connected) { @@ -154,6 +155,7 @@ export class WatcherProcessSupervisor { private handleChildMessage(message: WatcherToHostMessage): void { const child = this.child + const disposalRevision = this.shutdown.disposalRevision handleWatcherSupervisorMessage(message, { records: this.records, pendingUnsubscribes: this.pendingUnsubscribes, @@ -174,6 +176,7 @@ export class WatcherProcessSupervisor { this.terminateUnavailableChild(child) } }, + shouldReportTerminalError: () => this.shutdown.disposalRevision === disposalRevision, killWatcherChildIfIdle: () => termination.ignoreWatcherTermination(this.killWatcherChildIfIdle()) }) @@ -197,7 +200,7 @@ export class WatcherProcessSupervisor { recoverWatcherRecordsAfterChildGone( this.records, this.crashFuse, - this.shutdownRequested, + this.shutdown.requested, () => this.ensureWatcherProcess(), sendWatcherSubscribe, () => { @@ -230,9 +233,9 @@ export class WatcherProcessSupervisor { (exited) => { this.terminatingChild = null if (!exited) { - this.shutdownRequested = true + this.shutdown.requested = true } - return !this.shutdownRequested + return !this.shutdown.requested }, () => this.ensureWatcherProcess(), sendWatcherSubscribe, @@ -297,9 +300,9 @@ export class WatcherProcessSupervisor { (exited) => { this.terminatingChild = null if (!exited) { - this.shutdownRequested = true + this.shutdown.requested = true } - return !this.shutdownRequested + return !this.shutdown.requested }, () => this.ensureWatcherProcess(), sendWatcherSubscribe, diff --git a/src/main/ipc/parcel-watcher-process.test.ts b/src/main/ipc/parcel-watcher-process.test.ts index be5000fc49a..4fc98835328 100644 --- a/src/main/ipc/parcel-watcher-process.test.ts +++ b/src/main/ipc/parcel-watcher-process.test.ts @@ -90,7 +90,7 @@ describe('subscribeViaWatcherProcess', () => { expect(callback).toHaveBeenCalledWith(null, events) }) - it('forwards watcher errors to the callback', async () => { + it('retires the failed native subscription before reporting its error', async () => { const callback = vi.fn() const promise = subscribeViaWatcherProcess('/repo', callback, {}) const child = currentChild() @@ -98,6 +98,10 @@ describe('subscribeViaWatcherProcess', () => { await promise child.emit('message', { op: 'watch-error', id, message: 'boom' }) + expect(callback).not.toHaveBeenCalled() + expect(child.kill).toHaveBeenCalledTimes(1) + child.emit('exit', 0, null) + await vi.waitFor(() => expect(callback).toHaveBeenCalledTimes(1)) expect(callback).toHaveBeenCalledWith(expect.objectContaining({ message: 'boom' }), []) }) @@ -126,7 +130,7 @@ describe('subscribeViaWatcherProcess', () => { expect(forkMock).toHaveBeenCalledTimes(1) }) - it('reports a terminal resubscribe failure separately from recoverable watch errors', async () => { + it('reports a resubscribe failure through the terminal hook', async () => { const callback = vi.fn() const onTerminalError = vi.fn() const promise = subscribeViaWatcherProcess('/repo', callback, {}, { onTerminalError }) diff --git a/src/main/ipc/parcel-watcher-shallow-subscription.test.ts b/src/main/ipc/parcel-watcher-shallow-subscription.test.ts index b2eefd73e4e..ca17ce4df5a 100644 --- a/src/main/ipc/parcel-watcher-shallow-subscription.test.ts +++ b/src/main/ipc/parcel-watcher-shallow-subscription.test.ts @@ -1,5 +1,6 @@ import { EventEmitter } from 'node:events' -import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { watch } from 'node:fs' +import { mkdir, mkdtemp, rename, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -103,6 +104,29 @@ describe('shallow watcher subscription', () => { } }) + it.skipIf(process.platform === 'win32')( + 'preserves a literal backslash in an included filename', + async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) + const events: string[] = [] + const subscription = startShallowWatcher( + root, + ['plugin\\name'], + (nextEvents) => events.push(...nextEvents.map((event) => event.path)), + (error) => { + throw error + } + ) + try { + emit(root, 'plugin\\name') + expect(events).toEqual([join(root, 'plugin\\name')]) + } finally { + await subscription.unsubscribe() + await rm(root, { recursive: true, force: true }) + } + } + ) + it('rebinds a nested directory that is replaced, which leaves fs.watch deaf', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) try { @@ -152,4 +176,79 @@ describe('shallow watcher subscription', () => { await rm(root, { recursive: true, force: true }) } }) + + it('retries a failed replacement binding and resyncs after recovery', async () => { + vi.useFakeTimers() + const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) + const logs = join(root, 'logs') + let subscription: ReturnType | undefined + try { + await mkdir(logs) + const events: string[] = [] + subscription = startShallowWatcher( + root, + ['HEAD', 'logs/HEAD'], + (nextEvents) => events.push(...nextEvents.map((event) => event.path)), + (error) => { + throw error + } + ) + const initialAttempts = vi.mocked(watch).mock.calls.length + await rename(logs, join(root, 'old-logs')) + await mkdir(logs) + vi.mocked(watch).mockImplementationOnce(() => { + throw new Error('ENOSPC: watch limit reached') + }) + + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(initialAttempts + 1)) + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(watch).toHaveBeenCalledTimes(initialAttempts + 2)) + + expect(events).toContain(join(logs, 'HEAD')) + events.length = 0 + emit(logs, 'HEAD') + expect(events).toEqual([join(logs, 'HEAD')]) + } finally { + await subscription?.unsubscribe() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) + } + }) + + it('resyncs included files when an initially missing directory becomes watchable', async () => { + vi.useFakeTimers() + const root = await mkdtemp(join(tmpdir(), 'orca-shallow-watcher-')) + let subscription: ReturnType | undefined + try { + const defaultWatch = vi.mocked(watch).getMockImplementation() + if (!defaultWatch) { + throw new Error('Missing watcher test implementation') + } + vi.mocked(watch) + .mockImplementationOnce(defaultWatch) + .mockImplementationOnce(() => { + throw new Error('ENOENT: logs directory does not exist') + }) + const events: string[] = [] + subscription = startShallowWatcher( + root, + ['HEAD', 'logs/HEAD'], + (nextEvents) => events.push(...nextEvents.map((event) => event.path)), + (error) => { + throw error + } + ) + const logs = join(root, 'logs') + await mkdir(logs) + await vi.advanceTimersByTimeAsync(30_000) + await vi.waitFor(() => expect(watcherState.has(logs)).toBe(true)) + + expect(events).toContain(join(logs, 'HEAD')) + } finally { + await subscription?.unsubscribe() + vi.useRealTimers() + await rm(root, { recursive: true, force: true }) + } + }) }) diff --git a/src/main/ipc/parcel-watcher-shallow-subscription.ts b/src/main/ipc/parcel-watcher-shallow-subscription.ts index 13319281535..639ee8b36f8 100644 --- a/src/main/ipc/parcel-watcher-shallow-subscription.ts +++ b/src/main/ipc/parcel-watcher-shallow-subscription.ts @@ -33,7 +33,9 @@ export function startShallowWatcher( ): ShallowWatcherSubscription { const pathsByDirectory = new Map>() for (const relativePath of relativePaths) { - const parts = relativePath.split(/[\\/]+/).filter(Boolean) + const parts = relativePath + .split(process.platform === 'win32' ? /[\\/]+/ : /\/+/) + .filter(Boolean) const fileName = parts.pop() if (!fileName) { continue @@ -66,11 +68,11 @@ export function startShallowWatcher( ) } - const watchDirectory = (parent: string, fileNames: Set, rebind = false): void => { + const watchDirectory = (parent: string, fileNames: Set, rebind = false): boolean => { const existing = watchers.get(parent) if (existing) { if (!rebind) { - return + return true } watchers.delete(parent) boundIdentities.delete(parent) @@ -102,11 +104,13 @@ export function startShallowWatcher( }) watcher.on('error', reportError) watchers.set(parent, watcher) + return true } catch (error) { // Nested metadata directories may not exist until Git creates them. if (parent === '') { reportError(error) } + return false } } @@ -139,11 +143,11 @@ export function startShallowWatcher( } // Either the directory appeared after we started, or it was replaced while // watched. Both leave the old binding deaf, so rebind and resync. - watchDirectory(parent, fileNames, true) - boundIdentities.set(parent, identity) - if (bound !== undefined) { - emitUpdates(parent, fileNames) + if (!watchDirectory(parent, fileNames, true)) { + return } + boundIdentities.set(parent, identity) + emitUpdates(parent, fileNames) } const rebindTimer = setInterval(() => { diff --git a/src/main/ipc/parcel-watcher-supervisor-message.ts b/src/main/ipc/parcel-watcher-supervisor-message.ts index e5fbce4342f..cc3e090c5ea 100644 --- a/src/main/ipc/parcel-watcher-supervisor-message.ts +++ b/src/main/ipc/parcel-watcher-supervisor-message.ts @@ -3,7 +3,8 @@ import type { WatcherCancellationTracker } from './parcel-watcher-cancellation-t import { handleWatcherHostMessage, type PendingWatcherUnsubscribe, - reportWatcherTerminalError + reportWatcherTerminalError, + retireHostWatcherSubscription } from './parcel-watcher-host-subscriptions' import { startInterruptedSubscribeTimeout, @@ -28,6 +29,7 @@ type WatcherSupervisorMessageContext = { ) => void restartAfterCancelledSubscribe: (child: ChildProcess | null) => void terminateUnavailableChild: (child: ChildProcess | null) => void + shouldReportTerminalError: () => boolean killWatcherChildIfIdle: () => void } @@ -61,13 +63,13 @@ export function handleWatcherSupervisorMessage( } } if (message.op === 'watch-error') { - handleWatcherHostMessage( - message, - context.records, - context.pendingUnsubscribes, - reportWatcherTerminalError, - context.killWatcherChildIfIdle - ) + if (record) { + void retireHostWatcherSubscription( + record, + new Error(message.message), + context.shouldReportTerminalError + ) + } return } if (message.op === 'cancel-requires-restart') { diff --git a/src/main/ipc/pty/ipc/codex-shared-server.test.ts b/src/main/ipc/pty/ipc/codex-shared-server.test.ts index 94715870d3c..2c457df160b 100644 --- a/src/main/ipc/pty/ipc/codex-shared-server.test.ts +++ b/src/main/ipc/pty/ipc/codex-shared-server.test.ts @@ -1,18 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -type Handler = (event: unknown, args: { id: string }) => Promise +type Handler = (event: unknown, args: { id: string }) => Promise type Session = { id: string; rootProcessId?: number; wslDistro?: string } const mocks = vi.hoisted(() => ({ handlers: new Map(), sessions: new Array(), hasProvider: vi.fn<(id: string) => boolean>(), - isPaneCodexOnSharedServer: vi.fn<(id: string, rootPid: number) => Promise>(), + findPaneCodexOnSharedServer: + vi.fn< + (id: string, rootPid: number) => Promise<{ command: string; shell: string | null } | null> + >(), resolveCodexPaneHome: vi.fn<(id: string) => string | null>(), isOnOrcaMirror: vi.fn<(id: string) => boolean>(), disable: vi.fn<(home: string) => Promise>(), disableOnMirror: vi.fn<(home: string) => Promise>(), - stop: vi.fn<(home: string) => Promise>() + stop: vi.fn<(home: string) => Promise>(), + legacyAdapters: new Array<{ protocolVersion: number; hasPty: (id: string) => boolean }>() })) vi.mock('../../pty-host-bindings', () => ({ getPtyIpc: () => ({ @@ -21,7 +25,7 @@ vi.mock('../../pty-host-bindings', () => ({ })) vi.mock('../../../codex/codex-shared-server-pane', () => ({ isCodexPaneOnOrcaMirrorHome: mocks.isOnOrcaMirror, - isPaneCodexOnSharedServer: mocks.isPaneCodexOnSharedServer, + findPaneCodexOnSharedServer: mocks.findPaneCodexOnSharedServer, resolveCodexPaneHome: mocks.resolveCodexPaneHome })) vi.mock('../../../codex/codex-shared-server-fix', () => ({ @@ -29,6 +33,9 @@ vi.mock('../../../codex/codex-shared-server-fix', () => ({ disableCodexSharedServerAutoStartOnOrcaMirror: mocks.disableOnMirror, stopCodexSharedServer: mocks.stop })) +vi.mock('../../../daemon/daemon-provider-routing', () => ({ + getLegacyDaemonAdapters: () => mocks.legacyAdapters +})) vi.mock('../provider/registry', () => ({ hasPtyProviderForInspection: mocks.hasProvider, getProviderForPty: () => ({ listProcesses: () => Promise.resolve(mocks.sessions) }) @@ -38,13 +45,14 @@ import { toAppSshPtyId } from '../../../providers/ssh-pty-id' import { ptyOwnership } from '../provider/ownership-state' import { installPtyCodexSharedServerIpcHandler } from './codex-shared-server' +// Each channel with its answer for a local pane and its answer when it refuses one. const CHANNELS = [ - 'pty:isCodexOnSharedServer', - 'pty:disableCodexSharedServerAutoStart', - 'pty:stopCodexSharedServer' + ['pty:isCodexOnSharedServer', { joined: true, openedBeforeWrapper: false }, { joined: false }], + ['pty:disableCodexSharedServerAutoStart', true, false], + ['pty:stopCodexSharedServer', true, false] ] as const -function invoke(channel: string, id: string): Promise { +function invoke(channel: string, id: string): Promise { const handler = mocks.handlers.get(channel) if (!handler) { throw new Error(`missing ${channel}`) @@ -58,18 +66,50 @@ beforeEach(() => { ptyOwnership.clear() mocks.sessions = [{ id: 'local-1', rootProcessId: 100 }] mocks.hasProvider.mockReturnValue(true) - mocks.isPaneCodexOnSharedServer.mockResolvedValue(true) + mocks.findPaneCodexOnSharedServer.mockResolvedValue({ command: 'codex', shell: 'zsh' }) mocks.resolveCodexPaneHome.mockReturnValue('/home/me/.codex') mocks.isOnOrcaMirror.mockReturnValue(false) mocks.disable.mockResolvedValue(true) mocks.disableOnMirror.mockResolvedValue(true) mocks.stop.mockResolvedValue(true) + mocks.legacyAdapters = [] installPtyCodexSharedServerIpcHandler({ getLocalPtyProviderStartupPromise: () => undefined }) }) describe('Codex shared-server IPC', () => { - it.each(CHANNELS)('%s answers for a local pane', async (channel) => { - expect(await invoke(channel, 'local-1')).toBe(true) + it.each(CHANNELS)('%s answers for a local pane', async (channel, answer) => { + expect(await invoke(channel, 'local-1')).toEqual(answer) + }) + + const ownedBy = (protocolVersion: number, ptyId = 'local-1') => ({ + protocolVersion, + hasPty: (id: string) => id === ptyId + }) + + it.each([ + ['zsh on a v36 daemon', 'zsh', [ownedBy(36)], true], + ['zsh on a v37 daemon', 'zsh', [ownedBy(37)], false], + ['fish on a v37 daemon', 'fish', [ownedBy(37)], true], + ['fish on a v38 daemon', 'fish', [ownedBy(38)], true], + ['fish on a v39 daemon', 'fish', [ownedBy(39)], false], + ['cmd.exe, which never gets the codex function', 'cmd', [ownedBy(36)], false], + ['an unknown shell', null, [ownedBy(36)], false], + ['zsh where the older daemon does not hold this pane', 'zsh', [ownedBy(36, 'other')], false] + ])('tells whether a new terminal would fix %s', async (_label, shell, adapters, old) => { + mocks.findPaneCodexOnSharedServer.mockResolvedValue({ command: 'codex', shell }) + mocks.legacyAdapters = adapters + expect(await invoke('pty:isCodexOnSharedServer', 'local-1')).toEqual({ + joined: true, + openedBeforeWrapper: old + }) + }) + + it('reads no daemon owner for a pane that has not joined', async () => { + const hasPty = vi.fn(() => true) + mocks.legacyAdapters = [{ protocolVersion: 36, hasPty }] + mocks.findPaneCodexOnSharedServer.mockResolvedValue(null) + expect(await invoke('pty:isCodexOnSharedServer', 'local-1')).toEqual({ joined: false }) + expect(hasPty).not.toHaveBeenCalled() }) it('runs the fix against the pane home', async () => { @@ -104,17 +144,18 @@ describe('Codex shared-server IPC', () => { ['a pane no provider holds', 'local-1', () => mocks.hasProvider.mockReturnValue(false)] ] - it.each(CHANNELS.flatMap((channel) => refusals.map((refusal) => [channel, ...refusal] as const)))( - '%s refuses %s', - async (channel, _label, id, arrange) => { - arrange() - expect(await invoke(channel, id)).toBe(false) - expect(mocks.isPaneCodexOnSharedServer).not.toHaveBeenCalled() - expect(mocks.disable).not.toHaveBeenCalled() - expect(mocks.disableOnMirror).not.toHaveBeenCalled() - expect(mocks.stop).not.toHaveBeenCalled() - } - ) + it.each( + CHANNELS.flatMap(([channel, , refused]) => + refusals.map((refusal) => [channel, ...refusal, refused] as const) + ) + )('%s refuses %s', async (channel, _label, id, arrange, refused) => { + arrange() + expect(await invoke(channel, id)).toEqual(refused) + expect(mocks.findPaneCodexOnSharedServer).not.toHaveBeenCalled() + expect(mocks.disable).not.toHaveBeenCalled() + expect(mocks.disableOnMirror).not.toHaveBeenCalled() + expect(mocks.stop).not.toHaveBeenCalled() + }) it.each(CHANNELS.slice(1))('%s runs nothing when the pane has no Codex home', async (channel) => { mocks.resolveCodexPaneHome.mockReturnValue(null) diff --git a/src/main/ipc/pty/ipc/codex-shared-server.ts b/src/main/ipc/pty/ipc/codex-shared-server.ts index 33d30d314a9..57221284fa1 100644 --- a/src/main/ipc/pty/ipc/codex-shared-server.ts +++ b/src/main/ipc/pty/ipc/codex-shared-server.ts @@ -1,8 +1,8 @@ import { getPtyIpc } from '../../pty-host-bindings' import { parseAppSshPtyId } from '../../../providers/ssh-pty-id' import { + findPaneCodexOnSharedServer, isCodexPaneOnOrcaMirrorHome, - isPaneCodexOnSharedServer, resolveCodexPaneHome } from '../../../codex/codex-shared-server-pane' import { @@ -10,9 +10,25 @@ import { disableCodexSharedServerAutoStartOnOrcaMirror, stopCodexSharedServer } from '../../../codex/codex-shared-server-fix' +import { getLegacyDaemonAdapters } from '../../../daemon/daemon-provider-routing' +import { + CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION, + CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION +} from '../../../daemon/daemon-protocol-version' +import type { CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command' import { ptyOwnership } from '../provider/ownership-state' import { getProviderForPty, hasPtyProviderForInspection } from '../provider/registry' +// Why per shell: a new terminal fixes Codex only where this build's daemon gives that shell Orca's +// codex function; cmd.exe and unrecognized shells never get one. +const CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL: ReadonlyMap = new Map([ + ['zsh', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['bash', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['powershell', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['pwsh', CODEX_NO_DAEMON_SHELL_LAUNCH_DAEMON_PROTOCOL_VERSION], + ['fish', CODEX_FISH_SHELL_FUNCTION_DAEMON_PROTOCOL_VERSION] +]) + type Deps = { getLocalPtyProviderStartupPromise: () => Promise | undefined } /** The pane's root pid when it is a local, non-WSL pane; otherwise null. */ @@ -37,21 +53,40 @@ async function findLocalPaneRootPid(deps: Deps, id: unknown): Promise( deps: Deps, channel: string, - run: (id: string, rootPid: number) => Promise + run: (id: string, rootPid: number) => Promise, + refused: T ): void { - getPtyIpc().handle(channel, async (_event, args: { id: string }): Promise => { + getPtyIpc().handle(channel, async (_event, args: { id: string }): Promise => { try { const rootPid = await findLocalPaneRootPid(deps, args?.id) - return rootPid === null ? false : await run(args.id, rootPid) + return rootPid === null ? refused : await run(args.id, rootPid) } catch { - return false + return refused } }) } +async function readPaneSharedServerStatus( + id: string, + rootPid: number +): Promise { + const codex = await findPaneCodexOnSharedServer(id, rootPid) + if (!codex) { + return { joined: false } + } + // Why: the pane's own daemon predates its shell's codex function, which a new terminal has. + const shellFunctionProtocol = CODEX_SHELL_FUNCTION_PROTOCOL_BY_SHELL.get(codex.shell ?? '') + const openedBeforeWrapper = + shellFunctionProtocol !== undefined && + getLegacyDaemonAdapters(getProviderForPty(id)).some( + (adapter) => adapter.hasPty(id) && adapter.protocolVersion < shellFunctionProtocol + ) + return { joined: true, openedBeforeWrapper } +} + /** Runs a fix command against the pane's own CODEX_HOME, never a guessed one. */ function runForPaneHome(fix: (codexHome: string) => Promise) { return async (id: string): Promise => { @@ -71,7 +106,7 @@ function disableForPane(id: string): Promise { // Why its own read: only a pane already showing Codex asks, so no cadence poll pays for argv. export function installPtyCodexSharedServerIpcHandler(deps: Deps): void { - handleLocalPane(deps, 'pty:isCodexOnSharedServer', isPaneCodexOnSharedServer) - handleLocalPane(deps, 'pty:disableCodexSharedServerAutoStart', disableForPane) - handleLocalPane(deps, 'pty:stopCodexSharedServer', runForPaneHome(stopCodexSharedServer)) + handleLocalPane(deps, 'pty:isCodexOnSharedServer', readPaneSharedServerStatus, { joined: false }) + handleLocalPane(deps, 'pty:disableCodexSharedServerAutoStart', disableForPane, false) + handleLocalPane(deps, 'pty:stopCodexSharedServer', runForPaneHome(stopCodexSharedServer), false) } diff --git a/src/main/ipc/registered-worktree-root-owner.ts b/src/main/ipc/registered-worktree-root-owner.ts index 37b1ed25e38..d1b45caf2f6 100644 --- a/src/main/ipc/registered-worktree-root-owner.ts +++ b/src/main/ipc/registered-worktree-root-owner.ts @@ -3,6 +3,29 @@ import { getRepoExecutionHostId } from '../../shared/execution-host' import type { Repo } from '../../shared/repo-types' import { hasRemoteFilesystemOwner } from './remote-filesystem-owner' +export type RegisteredOwner = { + repoId: string + listed: Set | null + /** The WSL distro whose Git produced `listed`; undefined is host Git. */ + listedWslDistro: string | undefined + recovered: Set + aliases: Set + revision: number + dirty: boolean +} + +export function createRegisteredOwner(repoId: string, revision: number): RegisteredOwner { + return { + repoId, + listed: null, + listedWslDistro: undefined, + recovered: new Set(), + aliases: new Set(), + revision, + dirty: true + } +} + export function getWorktreeRootOwnerKey(repo: Repo): string { return JSON.stringify([repo.id, resolve(repo.path), getRepoExecutionHostId(repo)]) } diff --git a/src/main/ipc/registered-worktree-root-probes.ts b/src/main/ipc/registered-worktree-root-probes.ts index ca295f02dd1..a1ba05441b9 100644 --- a/src/main/ipc/registered-worktree-root-probes.ts +++ b/src/main/ipc/registered-worktree-root-probes.ts @@ -3,16 +3,27 @@ import { resolve } from 'node:path' import { withTimeout } from '../../shared/promise-timeout-fallback' import type { Repo } from '../../shared/repo-types' import { getErrorCode } from '../git/worktree-operation-options' +import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution' +import type { Store } from '../persistence' +import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options' import { listRepoWorktreeGraph } from '../repo-worktrees' const CREATED_WORKTREE_ROOT_PROBE_TIMEOUT_MS = 1_000 const AUTHORIZED_ROOTS_REBUILD_CONCURRENCY = 8 -type ListedRoots = { roots: Set; listingFailed: boolean } +/** `wslDistro` names the Git that listed the roots; undefined is host Git. */ +type ListedRoots = { roots: Set; listingFailed: boolean; wslDistro: string | undefined } +/** + * Why the project runtime's distro: the catalog and removal list through it, and WSL Git records a + * `C:\` worktree as `/mnt/c/...`, which host Git reading that metadata names as another path. A + * runtime awaiting repair has no distro, so it keeps the host Git this listing always used. + */ export async function listWorktreeRootsWithConcurrency( + store: Store, repos: readonly Repo[] ): Promise { + const runtimes = resolveLocalProjectRuntimesForRepos(store, repos) const results: ListedRoots[] = [] let nextIndex = 0 await Promise.all( @@ -22,10 +33,14 @@ export async function listWorktreeRootsWithConcurrency( while (nextIndex < repos.length) { const index = nextIndex++ const repo = repos[index] + const wslDistro = getWorktreeMirrorDistroForRuntime(runtimes.get(repo.id)) const roots = new Set([resolve(repo.path)]) let listingFailed = false try { - for (const worktree of await listRepoWorktreeGraph(repo)) { + for (const worktree of await listRepoWorktreeGraph( + repo, + wslDistro ? { wslDistro } : {} + )) { roots.add(resolve(worktree.path)) } } catch (error) { @@ -35,7 +50,7 @@ export async function listWorktreeRootsWithConcurrency( ) listingFailed = true } - results[index] = { roots, listingFailed } + results[index] = { roots, listingFailed, wslDistro } } } ) diff --git a/src/main/ipc/registered-worktree-root-relist-policy.ts b/src/main/ipc/registered-worktree-root-relist-policy.ts index 7707bcce983..b7348a2f5f1 100644 --- a/src/main/ipc/registered-worktree-root-relist-policy.ts +++ b/src/main/ipc/registered-worktree-root-relist-policy.ts @@ -4,6 +4,12 @@ * Split out of registered-worktree-roots-cache so the policy is testable on its own * and the cache module stays within its line budget. */ +import type { Repo } from '../../shared/repo-types' +import { resolveLocalProjectRuntimesForRepos } from '../local-project-runtime-resolution' +import type { Store } from '../persistence' +import { getWorktreeMirrorDistroForRuntime } from '../project-runtime-git-options' +import type { RegisteredOwner } from './registered-worktree-root-owner' + /** The owner fields the policy reads; `undefined` means no owner record yet. */ export type RelistCandidate = | { dirty: boolean; listed: unknown; recovered: { size: number } } @@ -24,3 +30,26 @@ export function shouldRelistOwner(owner: RelistCandidate, onlyDirty: boolean): b } return owner.dirty || owner.listed == null || owner.recovered.size > 0 } + +/** + * Owners whose roots came from a Git other than the one their project runtime now selects. + * + * A runtime awaiting repair is skipped: it names no Git, and re-listing through host Git would + * revoke worktrees only the last real listing can name. Its owners keep that listing. + */ +export function findOwnersListedThroughAnotherGit( + store: Store, + repos: ReadonlyMap, + owners: ReadonlyMap +): RegisteredOwner[] { + const runtimes = resolveLocalProjectRuntimesForRepos(store, [...repos.values()]) + return [...repos].flatMap(([key, repo]) => { + const owner = owners.get(key) + const runtime = runtimes.get(repo.id) + return owner?.listed && + runtime?.status !== 'repair-required' && + owner.listedWslDistro !== getWorktreeMirrorDistroForRuntime(runtime) + ? [owner] + : [] + }) +} diff --git a/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts b/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts new file mode 100644 index 00000000000..6d516a3574a --- /dev/null +++ b/src/main/ipc/registered-worktree-root-wsl-runtime.test.ts @@ -0,0 +1,219 @@ +import { join, resolve } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from '../persistence' +import type { Repo } from '../../shared/repo-types' +import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' + +const mocks = vi.hoisted(() => ({ graph: vi.fn(), runtimes: vi.fn(), realpath: vi.fn() })) +vi.mock('node:fs', () => ({ realpathSync: (path: string) => path })) +vi.mock('node:fs/promises', () => ({ stat: vi.fn(async () => ({})), realpath: mocks.realpath })) +vi.mock('../repo-worktrees', () => ({ + listRepoWorktreeGraph: mocks.graph, + isRepoRoot: vi.fn(() => false) +})) +vi.mock('../local-project-runtime-resolution', () => ({ + resolveLocalProjectRuntimesForRepos: mocks.runtimes +})) +import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from './filesystem-auth' +import { + __resetCreatedWorktreeRootsForTests, + invalidateAuthorizedRootsCache, + registerWorktreeRootsForRepo, + resolveRegisteredWorktreePath +} from './registered-worktree-roots-cache' + +const DISTRO = 'Ubuntu-24.04' +const repo: Repo = { + id: 'wsl-drive-repo', + path: resolve('/wsl-drive/repo'), + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 +} +// The same checkout as each Git names it: WSL Git's `/mnt/c/...` translated to the drive spelling +// every other consumer sends, and host Git reading that `/mnt/c` metadata as a different path. +const worktree = resolve('/wsl-drive/workspaces/feature-extra') +const hostGitSpelling = resolve('/mnt/c/wsl-drive/workspaces/feature-extra') +const hostRepo: Repo = { ...repo, id: 'host-repo', path: resolve('/host-drive/repo') } +const hostRepoWorktree = resolve('/host-drive/workspaces/feature') + +const wslRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'wsl', + hostPlatform: 'wsl', + distro: DISTRO, + projectId: 'project', + reason: 'project-override', + cacheKey: `project:wsl:${DISTRO}` + } +} +const hostRuntime: ProjectExecutionRuntimeResolution = { + status: 'resolved', + runtime: { + kind: 'windows-host', + hostPlatform: 'win32', + projectId: 'project', + reason: 'project-override', + cacheKey: 'project:windows-host' + } +} +const repairRuntime: ProjectExecutionRuntimeResolution = { + status: 'repair-required', + repair: { + projectId: 'project', + preferredRuntime: { kind: 'wsl', distro: DISTRO }, + reason: 'wsl-distro-missing', + source: 'project-override', + cacheKey: `project:repair:wsl-distro-missing:${DISTRO}` + } +} + +function fixture(repos: Repo[] = [repo]): Store { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: authorization reads only these store methods; listing and runtime resolution are mocked. + return { + getRepos: () => repos, + getProjectGroups: () => [], + getFolderWorkspaces: () => [], + getSettings: () => ({}) + } as unknown as Store +} + +function useRuntime(runtime: ProjectExecutionRuntimeResolution): void { + mocks.runtimes.mockImplementation( + (_store: Store, repos: readonly Repo[]) => + new Map(repos.map((entry) => [entry.id, entry.id === hostRepo.id ? hostRuntime : runtime])) + ) +} + +beforeEach(() => { + invalidateAuthorizedRootsCache() + __resetCreatedWorktreeRootsForTests() + vi.resetAllMocks() + mocks.realpath.mockImplementation(async (path: string) => path) + mocks.graph.mockImplementation(async (listed: Repo, options?: { wslDistro?: string }) => [ + { + path: + listed.id === hostRepo.id + ? hostRepoWorktree + : options?.wslDistro === DISTRO + ? worktree + : hostGitSpelling + } + ]) + useRuntime(wslRuntime) +}) + +describe('authorized worktree roots for a drive repo whose project runtime is WSL', () => { + it('lists through the project distro, so the path delete and git status send is authorized', async () => { + await expect(resolveAuthorizedPath(worktree, fixture())).resolves.toBe(worktree) + await expect(resolveRegisteredWorktreePath(worktree, fixture())).resolves.toBe(worktree) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO }) + }) + + it('still refuses a path outside every root, including the spelling only host Git lists', async () => { + const store = fixture() + await expect( + resolveAuthorizedPath(resolve('/wsl-drive/elsewhere/secret'), store) + ).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE) + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + }) + + it('re-lists through the new Git when the project runtime changes after a listing', async () => { + useRuntime(hostRuntime) + const store = fixture() + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).resolves.toBe( + join(hostGitSpelling, 'file') + ) + + useRuntime(wslRuntime) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + expect(mocks.graph).toHaveBeenLastCalledWith(repo, { wslDistro: DISTRO }) + // The host-Git listing is retired with its owner, not kept beside the new one. + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + }) + + it('keeps a registration WSL Git produced without re-listing', async () => { + const store = fixture() + registerWorktreeRootsForRepo(store, repo.id, [repo.path, worktree], { wslDistro: DISTRO }) + await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + expect(mocks.graph).not.toHaveBeenCalled() + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + }) + + it('re-lists a registration host Git produced for a WSL project on the next miss', async () => { + const store = fixture() + registerWorktreeRootsForRepo(store, repo.id, [repo.path, hostGitSpelling]) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO }) + }) + + it('lists with host Git while the runtime awaits repair, as before routing', async () => { + useRuntime(repairRuntime) + await expect(resolveAuthorizedPath(join(hostGitSpelling, 'file'), fixture())).resolves.toBe( + join(hostGitSpelling, 'file') + ) + expect(mocks.graph).toHaveBeenCalledWith(repo, {}) + }) + + it('keeps the WSL listing when a resolved runtime starts awaiting repair', async () => { + const store = fixture() + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + + useRuntime(repairRuntime) + await expect(resolveAuthorizedPath(resolve('/wsl-drive/outside'), store)).rejects.toThrow( + PATH_ACCESS_DENIED_MESSAGE + ) + expect(mocks.graph).toHaveBeenCalledTimes(1) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + }) + + it('re-lists in the same request when the runtime changes during an in-flight rebuild', async () => { + useRuntime(hostRuntime) + const store = fixture() + let releaseHostListing = (): void => {} + mocks.graph.mockImplementationOnce( + (_repo: Repo) => + new Promise((resolveListing) => { + releaseHostListing = () => resolveListing([{ path: hostGitSpelling }]) + }) + ) + const earlier = resolveAuthorizedPath(resolve('/wsl-drive/outside'), store) + await vi.waitFor(() => expect(mocks.graph).toHaveBeenCalledTimes(1)) + + useRuntime(wslRuntime) + const afterSwitch = resolveAuthorizedPath(join(worktree, 'file'), store) + releaseHostListing() + await expect(earlier).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE) + await expect(afterSwitch).resolves.toBe(join(worktree, 'file')) + }) + + it('keeps a host-runtime repo in the same cache on host Git', async () => { + const store = fixture([repo, hostRepo]) + await expect(resolveAuthorizedPath(join(hostRepoWorktree, 'file'), store)).resolves.toBe( + join(hostRepoWorktree, 'file') + ) + await expect(resolveAuthorizedPath(join(worktree, 'file'), store)).resolves.toBe( + join(worktree, 'file') + ) + expect(mocks.graph).toHaveBeenCalledWith(hostRepo, {}) + expect(mocks.graph).toHaveBeenCalledWith(repo, { wslDistro: DISTRO }) + expect(mocks.graph).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/main/ipc/registered-worktree-roots-cache.ts b/src/main/ipc/registered-worktree-roots-cache.ts index ba538356b9a..ecdabd46abb 100644 --- a/src/main/ipc/registered-worktree-roots-cache.ts +++ b/src/main/ipc/registered-worktree-roots-cache.ts @@ -6,21 +6,17 @@ import { pruneCreatedWorktreeRoots } from './registered-worktree-root-probes' import { isDescendantOrEqual, normalizeExistingPath } from './filesystem-path-containment' -import { shouldRelistOwner } from './registered-worktree-root-relist-policy' import { + findOwnersListedThroughAnotherGit, + shouldRelistOwner +} from './registered-worktree-root-relist-policy' +import { + createRegisteredOwner, getLocalWorktreeRootOwners, - resolveWorktreeRootOwner + resolveWorktreeRootOwner, + type RegisteredOwner } from './registered-worktree-root-owner' -type RegisteredOwner = { - repoId: string - listed: Set | null - recovered: Set - aliases: Set - revision: number - dirty: boolean -} - const registeredWorktreeRoots = new Set() const registeredOwners = new Map() const registeredWorktreeRootsRevisionByRepo = new Map() @@ -45,6 +41,16 @@ function resolveOwnerForRepo(store: Store, repo: Repo | string): RegisteredOwner return key === undefined ? undefined : registeredOwners.get(key) } +function relistOwners(owners: Iterable): void { + for (const owner of owners) { + owner.listed = null + owner.dirty = true + advanceOwner(owner) + } + refreshRegisteredWorktreeRoots() + registeredWorktreeRootsDirty = true +} + function synchronizeOwners(store: Store): Repo[] { const repos = store.getRepos() const owners = getLocalWorktreeRootOwners(repos) @@ -63,14 +69,7 @@ function synchronizeOwners(store: Store): Repo[] { } for (const [key, repo] of owners) { if (!registeredOwners.has(key)) { - registeredOwners.set(key, { - repoId: repo.id, - listed: null, - recovered: new Set(), - aliases: new Set(), - revision: ++revisionSequence, - dirty: true - }) + registeredOwners.set(key, createRegisteredOwner(repo.id, ++revisionSequence)) changed = true } } @@ -84,13 +83,7 @@ function synchronizeOwners(store: Store): Repo[] { export function invalidateAuthorizedRootsCache(): void { invalidationGeneration++ - for (const owner of registeredOwners.values()) { - owner.listed = null - owner.dirty = true - advanceOwner(owner) - } - refreshRegisteredWorktreeRoots() - registeredWorktreeRootsDirty = true + relistOwners(registeredOwners.values()) baseRevision = ++revisionSequence registeredWorktreeRootsRevisionByRepo.clear() } @@ -111,7 +104,10 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals // every registered repo. An owner with no listing yet (`listed === null`) is // always included, so a first rebuild is unchanged. .filter((entry) => shouldRelistOwner(entry.owner, onlyDirty)) - const listings = await listWorktreeRootsWithConcurrency(pending.map((entry) => entry.repo)) + const listings = await listWorktreeRootsWithConcurrency( + store, + pending.map((entry) => entry.repo) + ) const results = pending.map((entry, index) => ({ ...entry, ...listings[index] })) const isCurrent = (entry: (typeof pending)[number]): boolean => generation === invalidationGeneration && @@ -134,6 +130,7 @@ export async function rebuildAuthorizedRootsCache(store: Store, onlyDirty = fals continue } entry.owner.listed = entry.roots + entry.owner.listedWslDistro = entry.wslDistro entry.owner.dirty = false entry.owner.recovered = entry.recovered advanceOwner(entry.owner) @@ -155,24 +152,23 @@ export function markAuthorizedRootsOwnerDirty(store: Store, repo: Repo | string) if (!owner) { return false } - owner.listed = null - owner.dirty = true - advanceOwner(owner) - refreshRegisteredWorktreeRoots() - registeredWorktreeRootsDirty = true + relistOwners([owner]) return true } +/** `listing.wslDistro` is the distro whose Git produced `worktreeRoots`; omitted is host Git. */ export function registerWorktreeRootsForRepo( store: Store, repo: Repo | string, - worktreeRoots: string[] + worktreeRoots: string[], + listing: { wslDistro?: string } = {} ): void { const owner = resolveOwnerForRepo(store, repo) if (!owner) { return } owner.listed = new Set(worktreeRoots.map((root) => resolve(root))) + owner.listedWslDistro = listing.wslDistro owner.dirty = false advanceOwner(owner) refreshRegisteredWorktreeRoots() @@ -218,7 +214,16 @@ export function getRegisteredWorktreeRootsRevision(repoId: string): number { export async function ensureAuthorizedRootsCache(store: Store): Promise { synchronizeOwners(store) // Follow one superseded refresh; continuous catalog churn must not pin authorization forever. - for (let attempt = 0; registeredWorktreeRootsDirty && attempt < 2; attempt++) { + for (let attempt = 0; attempt < 2; attempt++) { + // Why re-derived each attempt: nothing invalidates this cache when a project's runtime changes, + // and a refresh that began before the change commits a listing from the old Git. + const drifted = findOwnersListedThroughAnotherGit(store, currentOwners, registeredOwners) + if (drifted.length > 0) { + relistOwners(drifted) + } + if (!registeredWorktreeRootsDirty) { + break + } if (!registeredWorktreeRootsRefresh) { registeredWorktreeRootsRefresh = rebuildAuthorizedRootsCache(store, true).finally(() => { registeredWorktreeRootsRefresh = null diff --git a/src/main/ipc/worktree-git-common-watch-reconciliation.test.ts b/src/main/ipc/worktree-git-common-watch-reconciliation.test.ts new file mode 100644 index 00000000000..464ecf828b5 --- /dev/null +++ b/src/main/ipc/worktree-git-common-watch-reconciliation.test.ts @@ -0,0 +1,159 @@ +import { join, resolve } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { subscribeViaWatcherProcess, type WatcherProcessCallback } from './parcel-watcher-process' +import { startGitCommonPolling } from './worktree-git-common-polling' +import { startGitCommonNarrowWatch } from './worktree-git-common-narrow-watch' +import { createGitCommonWatchReconciliation } from './worktree-git-common-watch-reconciliation' +import type { + WorktreeBaseSubscription, + WorktreePollerWindowVisibility +} from './worktree-base-directory-poller' + +vi.mock('node:fs/promises', () => ({ stat: vi.fn(async () => ({ isDirectory: () => true })) })) +vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: vi.fn() })) +vi.mock('./worktree-git-common-polling', () => ({ startGitCommonPolling: vi.fn() })) + +const commonDirPath = resolve('repository', '.git') +const visibility: WorktreePollerWindowVisibility = { + isWindowVisible: () => true, + onWindowBecameVisible: () => () => {} +} +const pollMock = vi.mocked(startGitCommonPolling) +const subscribeMock = vi.mocked(subscribeViaWatcherProcess) +const baselines: { + settle: () => void + unsubscribe: ReturnType Promise>> + onVisible: ReturnType void>> +}[] = [] +const starts: Promise[] = [] +const subscriptions: { callback: WatcherProcessCallback }[] = [] +const reconciliations: ReturnType[] = [] + +beforeEach(() => { + pollMock.mockReset() + subscribeMock.mockReset() + pollMock.mockImplementation((_path, _onEvents, _interval, pollVisibility) => { + const { promise, resolve } = Promise.withResolvers() + const onVisible = vi.fn<() => void>() + let removeVisibilityListener = (): void => {} + let ready = false + const unsubscribe = vi.fn(async () => removeVisibilityListener()) + baselines.push({ + settle: () => { + if (!ready) { + ready = true + removeVisibilityListener = pollVisibility.onWindowBecameVisible(onVisible) + resolve({ unsubscribe }) + } + }, + unsubscribe, + onVisible + }) + return promise + }) + subscribeMock.mockImplementation(async (_path, callback) => { + subscriptions.push({ callback }) + return { unsubscribe: vi.fn<() => Promise>().mockResolvedValue(undefined) } + }) +}) + +afterEach(async () => { + for (const baseline of baselines) { + baseline.settle() + } + for (const starting of starts) { + await (await starting).unsubscribe() + } + for (const reconciliation of reconciliations) { + await reconciliation.unsubscribe() + } + baselines.length = 0 + starts.length = 0 + subscriptions.length = 0 + reconciliations.length = 0 + vi.useRealTimers() +}) + +function createReconciliation(shouldKeep = () => true) { + const reconciliation = createGitCommonWatchReconciliation({ + commonDirPath, + pollIntervalMs: 10, + visibility, + canStart: () => true, + shouldKeep, + onRootReplacement: vi.fn(), + onEvents: vi.fn() + }) + reconciliations.push(reconciliation) + return reconciliation +} + +describe('Git common watcher reconciliation ownership', () => { + it('shares one pending baseline between concurrent starts', async () => { + const reconciliation = createReconciliation() + const first = reconciliation.ensureStarted() + const second = reconciliation.ensureStarted() + expect(pollMock).toHaveBeenCalledOnce() + baselines[0]?.settle() + await Promise.all([first, second]) + reconciliation.notifyWindowBecameVisible() + expect(baselines[0]?.onVisible).toHaveBeenCalledOnce() + await reconciliation.unsubscribe() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + }) + + it('keeps one poller when a native failure rearms during the initial baseline', async () => { + vi.useFakeTimers() + const starting = startGitCommonNarrowWatch( + { key: 'git-common', kind: 'git-common', path: commonDirPath, repos: new Map() }, + vi.fn(), + 10, + 'darwin', + visibility + ) + starts.push(starting) + await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce()) + subscriptions[0]?.callback(new Error('root failed during baseline'), []) + await vi.advanceTimersByTimeAsync(20) + expect(subscribeMock).toHaveBeenCalledTimes(2) + expect(subscribeMock.mock.calls[1]?.[0]).toBe(join(commonDirPath, 'worktrees')) + expect(pollMock).toHaveBeenCalledOnce() + baselines[0]?.settle() + const subscription = await starting + await vi.advanceTimersByTimeAsync(0) + await subscription.unsubscribe() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + }) + + it.each([true, false])( + 'waits for and releases a late baseline when shouldKeep returns %s', + async (keep) => { + const reconciliation = createReconciliation(() => keep) + const starting = reconciliation.ensureStarted() + const stopping = reconciliation.unsubscribe() + const stopped = vi.fn() + void stopping.then(stopped) + await Promise.resolve() + await Promise.resolve() + expect(stopped).not.toHaveBeenCalled() + baselines[0]?.settle() + await Promise.all([starting, stopping]) + expect(stopped).toHaveBeenCalledOnce() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + reconciliation.notifyWindowBecameVisible() + expect(baselines[0]?.onVisible).not.toHaveBeenCalled() + } + ) + + it('retries a rejected baseline and releases the successful retry', async () => { + pollMock.mockRejectedValueOnce(new Error('baseline failed')) + const reconciliation = createReconciliation() + await expect(reconciliation.ensureStarted()).rejects.toThrow('baseline failed') + const retry = reconciliation.ensureStarted() + expect(pollMock).toHaveBeenCalledTimes(2) + baselines[0]?.settle() + await retry + await reconciliation.unsubscribe() + expect(baselines[0]?.unsubscribe).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/worktree-git-common-watch-reconciliation.ts b/src/main/ipc/worktree-git-common-watch-reconciliation.ts index 662ea13a375..4f1aeb5d476 100644 --- a/src/main/ipc/worktree-git-common-watch-reconciliation.ts +++ b/src/main/ipc/worktree-git-common-watch-reconciliation.ts @@ -5,6 +5,7 @@ import type { WorktreePollerWindowVisibility } from './worktree-base-directory-poller' import { startGitCommonPolling } from './worktree-git-common-polling' +import { createSingleFlight } from './single-flight-promise' // The native stream is still the fast path. A scheduled 15-tick reconciliation // bounds silent watcher loss at the existing 30-second backstop without joining @@ -38,6 +39,8 @@ export function createGitCommonWatchReconciliation({ }: GitCommonWatchReconciliationOptions): GitCommonWatchReconciliation { const worktreesDir = join(commonDirPath, 'worktrees') let subscription: WorktreeBaseSubscription | null = null + const starting = createSingleFlight() + let generation = 0 const visibilityListeners = new Set<() => void>() const pollVisibility: WorktreePollerWindowVisibility = { isWindowVisible: visibility.isWindowVisible, @@ -50,57 +53,60 @@ export function createGitCommonWatchReconciliation({ } return { - ensureStarted: async () => { + ensureStarted: () => { if (subscription || !canStart()) { - return + return Promise.resolve() } - const reconciliation = await startGitCommonPolling( - commonDirPath, - (events) => { - const rootWasReplaced = - events.some((event) => event.type === 'delete' && event.path === worktreesDir) && - events.some((event) => event.type === 'create' && event.path === worktreesDir) - // Why: this backstop lags the native stream by up to 15 ticks, so it - // routinely reports entry creates the stream already delivered. Only - // treat them as a replacement when the root itself was also recreated - // — otherwise every ordinary `git worktree add` would tear down a - // healthy stream and open a deaf window while it resubscribes. - const rootRecreated = events.some( - (event) => event.type === 'create' && event.path === worktreesDir - ) - const coarseRootReplacement = - rootRecreated && - events.some( - (event) => - event.type === 'create' && - event.path !== worktreesDir && - dirname(event.path) === worktreesDir + return starting.run(async () => { + const startGeneration = generation + const reconciliation = await startGitCommonPolling( + commonDirPath, + (events) => { + const rootWasReplaced = + events.some((event) => event.type === 'delete' && event.path === worktreesDir) && + events.some((event) => event.type === 'create' && event.path === worktreesDir) + // Why: this backstop lags the native stream by up to 15 ticks, so it + // routinely reports entry creates the stream already delivered. Only + // treat them as a replacement when the root itself was also recreated + // — otherwise every ordinary `git worktree add` would tear down a + // healthy stream and open a deaf window while it resubscribes. + const rootRecreated = events.some( + (event) => event.type === 'create' && event.path === worktreesDir ) - if (rootWasReplaced || coarseRootReplacement) { - onRootReplacement() - } - onEvents( - coarseRootReplacement - ? events.map((event) => - event.type === 'update' && event.path === worktreesDir - ? { ...event, type: 'create' } - : event - ) - : events - ) - }, - pollIntervalMs * NARROW_WATCH_RECONCILIATION_TICKS, - pollVisibility, - undefined, - false, - () => [], - { forceFullScanEveryTick: true } - ) - if (!shouldKeep()) { - await reconciliation.unsubscribe() - } else { - subscription = reconciliation - } + const coarseRootReplacement = + rootRecreated && + events.some( + (event) => + event.type === 'create' && + event.path !== worktreesDir && + dirname(event.path) === worktreesDir + ) + if (rootWasReplaced || coarseRootReplacement) { + onRootReplacement() + } + onEvents( + coarseRootReplacement + ? events.map((event) => + event.type === 'update' && event.path === worktreesDir + ? { ...event, type: 'create' } + : event + ) + : events + ) + }, + pollIntervalMs * NARROW_WATCH_RECONCILIATION_TICKS, + pollVisibility, + undefined, + false, + () => [], + { forceFullScanEveryTick: true } + ) + if (startGeneration !== generation || !shouldKeep()) { + await reconciliation.unsubscribe() + } else { + subscription = reconciliation + } + }) }, notifyWindowBecameVisible: () => { for (const listener of visibilityListeners) { @@ -108,6 +114,8 @@ export function createGitCommonWatchReconciliation({ } }, unsubscribe: async () => { + generation++ + await starting.pending()?.catch(() => {}) const current = subscription subscription = null await current?.unsubscribe() diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 11b897b81db..a505cfe3da2 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -148,7 +148,11 @@ import { } from '../../shared/setup-runner-command' import { createSequencedSetupAgentCommands } from '../../shared/setup-agent-sequencing' import { shouldWaitForSetupBeforeAgentStartup } from '../../shared/setup-agent-startup-policy' -import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' +import { + createWorktreeCreateTimingRecorder, + localWorktreeCreateExecutionHost, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' import { getLocalProjectGitExecOptions, getLocalProjectWorktreeGitOptions, @@ -1717,9 +1721,10 @@ export async function createRemoteWorktree( args: CreateWorktreeArgsWithSystemProvenance, repo: Repo, store: Store, - mainWindow: BrowserWindow + mainWindow: BrowserWindow, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() ): Promise { - const timing = createWorktreeCreateTimingRecorder() + timing.recordExecutionHost('ssh') const provider = requireSshGitProvider(repo.connectionId!) const fsProvider = getSshFilesystemProvider(repo.connectionId!) @@ -1976,7 +1981,8 @@ export async function createRemoteWorktree( ) { // Why: only OLD relays (pre-allowlist-removal) throw these; surface an upgrade message. Remove after version floor moves (docs/relay-fs-allowlist-removal.md). throw new Error( - `Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})` + `Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})`, + { cause: err } ) } throw err @@ -2030,6 +2036,7 @@ export async function createRemoteWorktree( const gitWorktrees = await timing.time('list_created_worktree', async () => provider.listWorktrees(repo.path) ) + timing.recordWorktreeCount(gitWorktrees.length) // Match the exact requested path first, then the exact branch ref. Suffix matching can // select an older `prefix/` worktree when the newly created row is present. const created = findCreatedWorktree(gitWorktrees, remotePath, branchName) @@ -2190,15 +2197,18 @@ export function createLocalWorktree( repo: Repo, store: Store, mainWindow: BrowserWindow, - runtime?: OrcaRuntimeService + runtime?: OrcaRuntimeService, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() ): Promise { // Why a holder fired in `finally`: consuming a prepared checkout leaves the pool one short, so a // create that fails after that point — include copy, push target, terminal startup — must still // arm the replacement. Fires exactly once, after startup on the success path. const rearm: PreparationRearmHolder = { fire: () => {} } return worktreeCreateGit - .run(() => performLocalWorktreeCreate(args, repo, store, mainWindow, rearm, runtime)) + .run(() => performLocalWorktreeCreate(args, repo, store, mainWindow, rearm, timing, runtime)) .finally(() => { + // Closes the create's measured window first, so its own re-arm is not counted against it. + timing.finish() rearm.fire() }) } @@ -2209,9 +2219,9 @@ async function performLocalWorktreeCreate( store: Store, mainWindow: BrowserWindow, rearm: PreparationRearmHolder, + timing: WorktreeCreateTimingRecorder, runtime?: OrcaRuntimeService ): Promise { - const timing = createWorktreeCreateTimingRecorder() const settings = store.getSettings() const worktreePathSettings = getWorktreePathSettings( repo, @@ -2220,6 +2230,7 @@ async function performLocalWorktreeCreate( ) const localGitExecOptions = getLocalProjectGitExecOptions(store, repo) const localWorktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + timing.recordExecutionHost(localWorktreeCreateExecutionHost(localGitExecOptions)) const hasLocalWorktreeGitOptions = Object.keys(localWorktreeGitOptions).length > 0 const localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }] = hasLocalWorktreeGitOptions ? [localWorktreeGitOptions] @@ -2628,11 +2639,6 @@ async function performLocalWorktreeCreate( options: preparedWorktreeOptions, timing }) - timing.recordPreparedCheckout( - prepared.status === 'hit' - ? { status: 'hit', retargeted: prepared.retargeted } - : { status: 'miss', reason: prepared.reason } - ) if (prepared.status === 'hit') { // Why deferred: re-arming is a full `reset --hard`; started here it would hold a // general admission slot for the rest of this create's own git. @@ -2750,6 +2756,9 @@ async function performLocalWorktreeCreate( } = await timing.time('list_created_worktree', async () => resolveCreatedWorktree(repo.path, worktreePath, branchName, localWorktreeGitOptions) ) + if (listingComplete) { + timing.recordWorktreeCount(gitWorktrees.length) + } const worktreeId = `${repo.id}::${created.path}` const now = Date.now() @@ -2828,10 +2837,12 @@ async function performLocalWorktreeCreate( // Why gated: registration replaces the repo's root set, so registering a create recovered without // a listing would revoke filesystem access to every worktree that listing would have named. if (listingComplete) { - registerWorktreeRootsForRepo(store, repo, [ - repo.path, - ...gitWorktrees.map((worktree) => worktree.path) - ]) + registerWorktreeRootsForRepo( + store, + repo, + [repo.path, ...gitWorktrees.map((worktree) => worktree.path)], + localWorktreeGitOptions + ) } else { // Recovered without a listing: authorize just the new root, or the create the user just made // is rejected by filesystem/git-status IPC until a full scan repopulates the cache. diff --git a/src/main/ipc/worktrees-create-event-timing.test.ts b/src/main/ipc/worktrees-create-event-timing.test.ts new file mode 100644 index 00000000000..4a40bd43b81 --- /dev/null +++ b/src/main/ipc/worktrees-create-event-timing.test.ts @@ -0,0 +1,359 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + addWorktreeMock, + getActiveMultiplexerMock, + getSshGitProviderMock, + gitExecFileAsyncMock, + listWorktreesMock, + setPlatform +} from './worktrees-test-module-mocks' +import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness' +import { beginPreparationWork } from '../worktree-create-concurrency' + +const { trackMock, probeHookMock, telemetryEnabledMock } = vi.hoisted(() => ({ + trackMock: vi.fn<(name: string, props: Record) => void>(), + probeHookMock: vi.fn(), + telemetryEnabledMock: vi.fn(() => true) +})) + +vi.mock('electron', async () => + (await import('./worktrees-test-module-mocks')).electronModuleMock() +) +vi.mock('../git/worktree', async () => + (await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock() +) +vi.mock('../git/runner', async () => + (await import('./worktrees-test-module-mocks')).gitRunnerModuleMock() +) +vi.mock('../git/repo', async () => + (await import('./worktrees-test-module-mocks')).gitRepoModuleMock() +) +vi.mock('../git/git-username', async (importOriginal) => ({ + ...(await importOriginal>()), + resolveLocalGitUsername: (await import('./worktrees-test-module-mocks')) + .resolveLocalGitUsernameMock +})) +vi.mock('../github/client', async () => + (await import('./worktrees-test-module-mocks')).githubClientModuleMock() +) +vi.mock('../source-control/hosted-review', async () => + (await import('./worktrees-test-module-mocks')).hostedReviewModuleMock() +) +vi.mock('../providers/ssh-git-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshGitDispatchModuleMock() +) +vi.mock('../providers/ssh-filesystem-dispatch', async () => + (await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock() +) +vi.mock('./worktree-symlinks', async () => + (await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock() +) +vi.mock('./ssh', async () => (await import('./worktrees-test-module-mocks')).sshModuleMock()) +vi.mock('../ssh/ssh-target-registry', async () => + (await import('./worktrees-test-module-mocks')).sshTargetRegistryModuleMock() +) +vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock()) +vi.mock('../setup-runner-script-text', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock( + await importOriginal>() + ) +) +vi.mock('../worktree-runner-script', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock( + await importOriginal>() + ) +) +vi.mock('../effective-hook-config', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock( + await importOriginal>() + ) +) +vi.mock('../setup-hook-env-vars', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock( + await importOriginal>() + ) +) +vi.mock('./worktree-logic', async (importOriginal) => + (await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock( + await importOriginal>() + ) +) +vi.mock('../terminal-history-deletion', async () => + (await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock() +) +vi.mock('../ports/advertised-url-watcher', async () => + (await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock() +) +vi.mock('../workspace-cleanup-scan-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock() +) +vi.mock('../workspace-space-analysis-snapshot', async () => + (await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock() +) +vi.mock('../workspace-cleanup-removal-snapshot-prune', async () => + (await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock() +) +vi.mock('../runtime/worktree-teardown', async () => + (await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock() +) +vi.mock('./pty', async () => (await import('./worktrees-test-module-mocks')).ptyModuleMock()) + +vi.mock('../telemetry/client', () => ({ + track: trackMock, + isTelemetryEnabled: telemetryEnabledMock +})) +vi.mock('../git/create-event-repo-probe', () => ({ + probeCreateEventRepoFacts: probeHookMock +})) + +function makeRepo(fields: Record) { + return { + id: 'repo-1', + path: '/workspace/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0, + worktreeBaseRef: 'origin/main', + ...fields + } +} + +function useRepo(repo: ReturnType): void { + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta) + getActiveMultiplexerMock.mockReturnValue({ + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + }) +} + +function useLocalListing(): void { + listWorktreesMock.mockResolvedValue([ + { path: '/workspace/repo', head: 'abc', branch: 'main', isBare: false, isMainWorktree: true }, + { path: '/workspace/wt', head: 'abc123', branch: 'wt', isBare: false, isMainWorktree: false } + ]) +} + +function useSshProvider() { + useRepo(makeRepo({ path: '/remote/repo', executionHostId: 'ssh:target-a' })) + const provider = { + exec: vi.fn().mockImplementation(async (args: string[]) => { + if (args[0] === 'remote') { + return { stdout: 'origin\n', stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing exact ref'), { code: 1 }) + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/repo-wt', + head: 'abc', + branch: 'refs/heads/wt', + isBare: false, + isMainWorktree: false + } + ]) + } + getSshGitProviderMock.mockImplementation((connectionId: string) => + connectionId === 'target-a' ? provider : undefined + ) + return provider +} + +function trackedEvents(name: string): Record[] { + return trackMock.mock.calls.filter(([eventName]) => eventName === name).map(([, props]) => props) +} + +function trackedEvent(name: string): Record | undefined { + const call = trackMock.mock.calls.find(([eventName]) => eventName === name) + return call?.[1] +} + +function gitWorkCallCount(): number { + return ( + gitExecFileAsyncMock.mock.calls.length + + addWorktreeMock.mock.calls.length + + listWorktreesMock.mock.calls.length + ) +} + +describe('worktrees:create event timing fields', () => { + beforeEach(() => { + setupWorktreeHandlers() + trackMock.mockReset() + probeHookMock.mockReset() + telemetryEnabledMock.mockReset() + telemetryEnabledMock.mockReturnValue(true) + }) + + it('sends timing after the create returns, without any further git work', async () => { + useRepo(makeRepo({})) + useLocalListing() + let resolveProbe: (value: { + postCheckoutHook: string + indexEntryCount?: number + }) => void = () => {} + probeHookMock.mockReturnValue( + new Promise((resolve) => { + resolveProbe = resolve + }) + ) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + + // The create already answered; the hook probe has not, so the event is not sent yet. + expect(trackedEvent('workspace_created')).toBeUndefined() + const gitCallsAtReturn = gitWorkCallCount() + + resolveProbe({ postCheckoutHook: 'present', indexEntryCount: 6_000 }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(gitWorkCallCount()).toBe(gitCallsAtReturn) + expect(probeHookMock).toHaveBeenCalledWith('/workspace/repo') + const props = trackedEvent('workspace_created') + expect(props).toMatchObject({ + source: 'unknown', + from_existing_branch: false, + create_entry_point: 'app', + execution_host: 'local', + worktree_count_bucket: '2-5', + concurrent_creates: 0, + concurrent_preparations: 0, + repo_file_count_bucket: '1k-10k', + post_checkout_hook: 'present' + }) + // One create, one event: the runtime entry point never runs for an app create. + expect(trackedEvents('workspace_created')).toHaveLength(1) + expect(trackedEvents('workspace_create_failed')).toHaveLength(0) + expect(typeof props?.total_ms).toBe('number') + expect(typeof props?.git_worktree_add_ms).toBe('number') + expect(props).toHaveProperty('prepared_checkout') + // Nothing that names the repo, the branch or a path rides along. + expect(JSON.stringify(props)).not.toMatch(/workspace|wt|repo-1/) + }) + + it('counts prepared-checkout work that ran alongside the create', async () => { + useRepo(makeRepo({})) + useLocalListing() + probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' }) + const build = beginPreparationWork() + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + build.end() + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(trackedEvent('workspace_created')).toMatchObject({ concurrent_preparations: 1 }) + }) + + it('still answers the create, or its error, when sending the event throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + useRepo(makeRepo({})) + useLocalListing() + probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' }) + trackMock.mockImplementation(() => { + throw new Error('telemetry broke') + }) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).resolves.toHaveProperty('worktree') + + addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir')) + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow('could not create work tree dir') + }) + + it('does not read the repo for hooks when telemetry is off', async () => { + useRepo(makeRepo({})) + useLocalListing() + telemetryEnabledMock.mockReturnValue(false) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(probeHookMock).not.toHaveBeenCalled() + expect(trackedEvent('workspace_created')).not.toHaveProperty('post_checkout_hook') + }) + + it.each([ + { repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', host: 'wsl' }, + { repoPath: '/workspace/repo', host: 'local' } + ])( + 'labels $repoPath as $host from where Git runs, with no WSL project runtime', + async ({ repoPath, host }) => { + setPlatform('win32') + useRepo(makeRepo({ path: repoPath })) + useLocalListing() + probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' }) + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(trackedEvent('workspace_created')).toMatchObject({ execution_host: host }) + } + ) + + it('records an SSH create without probing the remote for hooks', async () => { + useSshProvider() + + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined()) + + expect(probeHookMock).not.toHaveBeenCalled() + const props = trackedEvent('workspace_created') + expect(props).toMatchObject({ execution_host: 'ssh', worktree_count_bucket: '1' }) + expect(props).not.toHaveProperty('post_checkout_hook') + expect(props).not.toHaveProperty('prepared_checkout') + }) + + it('attributes an old-relay SSH add error to the add through its cause', async () => { + const provider = useSshProvider() + provider.addWorktree.mockRejectedValue(new Error('Path outside authorized workspace: /x')) + + let caught: unknown + try { + await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + } catch (error) { + caught = error + } + + expect(caught).toBeInstanceOf(Error) + expect(caught instanceof Error && caught.message).toMatch(/^Older relay reported/) + expect(caught instanceof Error && caught.cause).toBeInstanceOf(Error) + expect(trackedEvent('workspace_create_failed')).toMatchObject({ + failed_phase: 'git_worktree_add', + execution_host: 'ssh' + }) + }) + + it('names the phase a failed create died in', async () => { + useRepo(makeRepo({})) + useLocalListing() + addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir')) + + await expect( + handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' }) + ).rejects.toThrow() + + const props = trackedEvent('workspace_create_failed') + expect(props).toMatchObject({ + failed_phase: 'git_worktree_add', + create_entry_point: 'app', + execution_host: 'local', + concurrent_creates: 0, + concurrent_preparations: 0, + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'none_armed' + }) + expect(trackedEvents('workspace_create_failed')).toHaveLength(1) + expect(trackedEvents('workspace_created')).toHaveLength(0) + expect(typeof props?.total_ms).toBe('number') + expect(JSON.stringify(props)).not.toMatch(/fatal|work tree/) + }) +}) diff --git a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts index 1ace3deb0b9..e8e03c0706a 100644 --- a/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts +++ b/src/main/ipc/worktrees/create/register-worktree-create-handlers.ts @@ -32,6 +32,7 @@ import { createFolderWorkspace } from './folder-workspace-creation' import { findExactRepoOwner, isCapturedRepoCurrent } from '../listing/worktree-host-ownership' import { requireWorktreeCreateRoute } from '../../../worktree-create-execution-host-route' import type { WorktreeIpcContext } from '../worktree-ipc-context' +import { beginWorkspaceCreateTelemetry } from '../../../workspace-create-telemetry' export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): void { const { mainWindow, store, runtime, options } = context @@ -47,9 +48,6 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi throw new Error(`Repo not found: ${args.repoId}`) } - const sourceParse = workspaceSourceSchema.safeParse(args.telemetrySource) - const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' - const automationProvenance = resolveAutomationWorkspaceProvenance({ authority: runtime, repoSelector: args.repoId, @@ -62,6 +60,14 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi } let result: CreateWorktreeResult + // The handler owns the events so a failed create can still say where it died. + const telemetry = beginWorkspaceCreateTelemetry({ + source: args.telemetrySource, + entryPoint: 'app', + repoPath: repo.path, + fromExistingBranch: typeof args.baseBranch === 'string' && args.baseBranch.length > 0, + isFolder: isFolderRepo(repo) + }) try { // Why: wrap only the helpers; the pre-validation throws above are IPC-shape bugs, not the git/filesystem failures the funnel tracks. if (isFolderRepo(repo)) { @@ -74,16 +80,25 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi const createRoute = requireWorktreeCreateRoute(repo) result = createRoute.kind === 'ssh' - ? await createRemoteWorktree(createArgs, createRoute.repo, store, mainWindow) - : await createLocalWorktree(createArgs, repo, store, mainWindow, runtime) + ? await createRemoteWorktree( + createArgs, + createRoute.repo, + store, + mainWindow, + telemetry.timing + ) + : await createLocalWorktree( + createArgs, + repo, + store, + mainWindow, + runtime, + telemetry.timing + ) } } catch (error) { releaseAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) - track('workspace_create_failed', { - source, - error_class: classifyWorkspaceCreateError(error), - ...getCohortAtEmit() - }) + telemetry.failed(error) throw error } finishAutomationWorkspaceProvenanceRequest(args.automationProvenanceRequest) @@ -92,14 +107,7 @@ export function registerWorktreeCreateHandlers(context: WorktreeIpcContext): voi } // Why: reaching here means create succeeded (helpers throw); skip a separate workspace_initialized (telemetry-plan.md§Deferred); never send the branch name. - track('workspace_created', { - source, - from_existing_branch: - !isFolderRepo(repo) && - typeof args.baseBranch === 'string' && - args.baseBranch.length > 0, - ...getCohortAtEmit() - }) + telemetry.succeeded(result.timing) if (isFolderRepo(repo)) { notifyWorktreesChanged(mainWindow, repo.id) diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts index dad44c64a69..4df3d379d4d 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-cache.ts @@ -47,6 +47,8 @@ export type DetectedWorktreeScan = { export type DetectedWorktreeSideEffectToken = Readonly<{ generation: number authorizedRootsRevision: number + /** The distro whose Git listed the scan; undefined is host Git. */ + wslDistro?: string }> export type DetectedWorktreeMetadataPrune = Readonly<{ @@ -173,7 +175,7 @@ export async function listDetectedGitWorktrees( const scan: DetectedWorktreeScan = { invalidated: false, promise: listRepoWorktreesForDetectedScan(repo, localWorktreeGitOptions), - sideEffectToken: { generation, authorizedRootsRevision }, + sideEffectToken: { generation, authorizedRootsRevision, ...localWorktreeGitOptions }, hygieneDue, ...(metadataPruneExpectation ? { @@ -269,7 +271,7 @@ export async function applyFreshDetectedWorktreeScanSideEffects( ) { return false } - rememberLocalWorktreeRoots(store, repo, gitWorktrees) + rememberLocalWorktreeRoots(store, repo, gitWorktrees, sideEffectToken) // Why: lineage retention is decided against the metadata rows the prune preserved, so running it // without that pass would drop lineage for rows the pass would have kept. Both halves share the // hygiene cadence instead. @@ -294,14 +296,17 @@ export function getDetectedWorktreeScanCacheKey( export function rememberLocalWorktreeRoots( store: Store, repo: Repo, - gitWorktrees: GitWorktreeInfo[] + gitWorktrees: GitWorktreeInfo[], + listing: { wslDistro?: string } = {} ): void { if (getRepoExecutionHostId(repo) !== LOCAL_EXECUTION_HOST_ID) { return } // Why: reuse the `git worktree list` result so later git/file IPC validation skips a second scan that can trigger macOS folder-permission prompts. - registerWorktreeRootsForRepo(store, repo, [ - repo.path, - ...gitWorktrees.map((worktree) => worktree.path) - ]) + registerWorktreeRootsForRepo( + store, + repo, + [repo.path, ...gitWorktrees.map((worktree) => worktree.path)], + listing + ) } diff --git a/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts new file mode 100644 index 00000000000..952ae1c68d2 --- /dev/null +++ b/src/main/ipc/worktrees/listing/detected-worktree-scan-listing-distro.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { Store } from '../../../persistence/loading-store/store' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + gitOptions: vi.fn(), + registerRoots: vi.fn() +})) + +vi.mock('../../../repo-worktrees', () => ({ listRepoWorktreesForDetectedScan: mocks.list })) +vi.mock('../../../project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.gitOptions +})) +vi.mock('../../registered-worktree-roots-cache', () => ({ + getRegisteredWorktreeRootsRevision: () => 1, + registerWorktreeRootsForRepo: mocks.registerRoots +})) +vi.mock('../../../worktree-lineage-pruning', () => ({ + pruneLineageForMissingRepoWorktrees: vi.fn() +})) + +const { + __resetDetectedWorktreeScanCacheForTests, + applyFreshDetectedWorktreeScanSideEffects, + listDetectedGitWorktrees +} = await import('./detected-worktree-scan-cache') + +const repo: Repo = { + id: 'repo-1', + path: '/repos/one', + displayName: 'one', + badgeColor: '#000', + addedAt: 0 +} +const worktree = { path: '/repos/one-feature', head: 'abc', branch: 'feature', isBare: false } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a first local scan reads only this store method; routing, listing and registration are mocked. +const store = { captureNativeLocalWorktreeMetadataScanExpectation: vi.fn() } as unknown as Store + +async function scanAndRegister(): Promise { + const scan = await listDetectedGitWorktrees(store, repo) + await applyFreshDetectedWorktreeScanSideEffects(store, repo, scan.gitWorktrees, undefined, { + sideEffectToken: scan.sideEffectToken, + hygieneDue: false + }) +} + +describe('detected worktree scan root registration', () => { + beforeEach(() => { + vi.resetAllMocks() + mocks.list.mockResolvedValue([{ ...worktree, isMainWorktree: false }]) + __resetDetectedWorktreeScanCacheForTests() + }) + + it('records the distro whose Git listed the scan', async () => { + mocks.gitOptions.mockReturnValue({ wslDistro: 'Ubuntu-24.04' }) + await scanAndRegister() + + expect(mocks.registerRoots).toHaveBeenCalledWith( + store, + repo, + [repo.path, worktree.path], + expect.objectContaining({ wslDistro: 'Ubuntu-24.04' }) + ) + }) + + it('records host Git for a scan listed without a distro', async () => { + mocks.gitOptions.mockReturnValue({}) + await scanAndRegister() + + expect(mocks.registerRoots).toHaveBeenCalledTimes(1) + expect(mocks.registerRoots.mock.calls[0]?.[3]?.wslDistro).toBeUndefined() + }) +}) diff --git a/src/main/observability/instrumentation.test.ts b/src/main/observability/instrumentation.test.ts index 139bb2494dd..a312bb9307d 100644 --- a/src/main/observability/instrumentation.test.ts +++ b/src/main/observability/instrumentation.test.ts @@ -219,16 +219,23 @@ describe('addWorktreeCreatePhaseAttributes', () => { expect(attributes['worktree.create.unattributed_ms']).toBe(200) }) - it('records a prepared-checkout hit and whether it had to be retargeted', () => { + it('records a prepared-checkout hit, the reset it needed and who armed it', () => { const { attributes, span } = capture() addWorktreeCreatePhaseAttributes(span, { totalDurationMs: 900, phases: [{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 400 }], - preparedCheckout: { status: 'hit', retargeted: true } + preparedCheckout: { + status: 'hit', + reset: 'retargeted', + origin: 'rearm', + buildMs: 1, + idleMs: 0 + } }) expect(attributes['worktree.create.prepared_checkout']).toBe('hit') - expect(attributes['worktree.create.prepared_checkout_retargeted']).toBe(true) + expect(attributes['worktree.create.prepared_checkout_reset']).toBe('retargeted') + expect(attributes['worktree.create.prepared_checkout_origin']).toBe('rearm') expect(attributes['worktree.create.prepared_checkout_miss']).toBeUndefined() expect(attributes['worktree.create.unattributed_ms']).toBe(500) }) @@ -243,7 +250,7 @@ describe('addWorktreeCreatePhaseAttributes', () => { expect(attributes['worktree.create.prepared_checkout']).toBe('miss') expect(attributes['worktree.create.prepared_checkout_miss']).toBe('base_mismatch') - expect(attributes['worktree.create.prepared_checkout_retargeted']).toBeUndefined() + expect(attributes['worktree.create.prepared_checkout_reset']).toBeUndefined() }) it('stays silent on paths that never consult the prepared checkout', () => { diff --git a/src/main/observability/instrumentation.ts b/src/main/observability/instrumentation.ts index bba248cd663..a010f6e95c7 100644 --- a/src/main/observability/instrumentation.ts +++ b/src/main/observability/instrumentation.ts @@ -270,11 +270,9 @@ export function addWorktreeCreatePhaseAttributes( if (timing.preparedCheckout) { span.setAttribute('worktree.create.prepared_checkout', timing.preparedCheckout.status) if (timing.preparedCheckout.status === 'hit') { - // A retargeted hit still pays a reset, so it must not be read as a free hit. - span.setAttribute( - 'worktree.create.prepared_checkout_retargeted', - timing.preparedCheckout.retargeted - ) + // A hit that had to reset still paid for it, so it must not be read as a free hit. + span.setAttribute('worktree.create.prepared_checkout_reset', timing.preparedCheckout.reset) + span.setAttribute('worktree.create.prepared_checkout_origin', timing.preparedCheckout.origin) } else { span.setAttribute('worktree.create.prepared_checkout_miss', timing.preparedCheckout.reason) } @@ -284,10 +282,15 @@ export function addWorktreeCreatePhaseAttributes( } // What the phases do not cover is the number that matters when create feels slow for no visible // reason, so name it rather than leaving it to subtraction. - span.setAttribute( - 'worktree.create.unattributed_ms', - Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) - ) + span.setAttribute('worktree.create.unattributed_ms', worktreeCreateUnattributedMs(timing)) +} + +/** Wall-clock time of a create that no timed phase covers; overlapping phases count once. */ +export function worktreeCreateUnattributedMs(timing: { + totalDurationMs: number + phases: readonly WorktreePhaseInterval[] +}): number { + return Math.max(0, Math.round(timing.totalDurationMs - measuredWallClockMs(timing.phases))) } /** Closed set so a typo can't silently mint an orphan span name. */ diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 8b00a816b6f..653e1b2d303 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -279,6 +279,13 @@ async function startOrcadRuntime( }) getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) + // Why: this host evaluates its own panes, so it keeps its own rules current. + const { startAgentStateRulesLiveUpdates } = + await import('../runtime/agent-state-rules/agent-state-rules-live-update') + startAgentStateRulesLiveUpdates(profileStore, (rules) => + console.info(`[orcad] agent state rules ${rules.version} (${rules.source})`) + ) + // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a // pane's status row changes, and orcad's whole job is serving paired clients. uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 3360fdfd944..d906798fb47 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -37,6 +37,10 @@ vi.mock('./orcad-daemon-supervision', () => ({ stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) +// Why: the real updater would fetch rules from GitHub inside a unit test. +vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({ + startAgentStateRulesLiveUpdates: () => {} +})) vi.mock('../daemon/daemon-init', () => ({ daemonOwnsFreshPersistentPtys: () => false })) vi.mock('../ipc/pty', () => ({ registerHeadlessPtyRuntime: async () => {}, diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index 4427b11f55d..b92b266f427 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -20,7 +20,9 @@ const AUDITED_NON_NET_FETCH_CALLS = new Map([ ['main/rate-limits/minimax/minimax-request-context.ts', 2], // Injected HttpClient, not a session: resolves to net.fetch on defaultSession // (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback. - ['main/jira/authenticated-request.ts', 1] + ['main/jira/authenticated-request.ts', 1], + // The same injected HttpClient, and the updater's deps.fetch that it is passed as. + ['main/runtime/agent-state-rules/agent-state-rules-live-update.ts', 2] ]) // `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts. diff --git a/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.meta.json b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.meta.json new file mode 100644 index 00000000000..75d232e1347 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.meta.json @@ -0,0 +1,9 @@ +{ + "capturedAt": "2026-10-02T05:46:28.602Z", + "platform": "darwin", + "command": ["codex", "--no-daemon", "-c", "check_for_update_on_startup=false"], + "cols": 120, + "rows": 40, + "note": "codex-cli 0.160.0, isolated HOME/CODEX_HOME with the project pre-trusted. A Plan-mode turn ends in the 'Implement this plan?' menu, which owns the screen until '3' (No, stay in Plan mode) answers it at 70s; cut 25s later at the empty composer.", + "exitCode": 0 +} diff --git a/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.txt b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.txt new file mode 100644 index 00000000000..62ae231e2c3 --- /dev/null +++ b/src/main/runtime/__fixtures__/codex-0-160-0-plan-implement-menu.txt @@ -0,0 +1 @@ +[?2004h[>4;0m[>7u[?1004h]10;?\]11;?\[?u[?2026h[?25l[?1049h[>4;0m[>7u[?1007l[?1000h[?1002h[?1003h[?1006h[?25l[?2026h[?25l>_ OpenAI Codex (v0.160.0)loadingHello, world. Hello, you.⣀⣤⣤⣤⣀⣀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄⣀⣾⣿⣿⠃⣀⣴⣾⣿⣿⡿⠟⠋⣀⡀⠈⠙⢿⣿⣿⣧⣀⣶⣿⣿⣿⣿⡇⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇⣰⣿⣿⡿⢿⣿⣿⡇⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷⢰⣿⣿⡟⠁⢸⣿⣿⡇⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀⣾⣿⣿⠃⢸⣿⣿⡇⢸⣿⣿⠋⠁⠈⠙⢻⣿⣿⣿⣶⣤⡀⠹⢿⣿⣷⡄⢸⣿⣿⣇⠸⣿⣿⣷⣦⣼⣿⣿⢸⣿⣿⠻⢿⣿⣿⡇⠘⣿⣿⣿⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀⢀⣠⣼⣿⣿⣿⣿⡇⣿⣿⣿⠇⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿⣿⣿⡇⣠⣿⣿⣿⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿⣿⣿⣷⣴⣿⣿⣿⠃⠘⣿⣿⣷⠈⠛⢿⣿⣿⣿⠿⠋⠉⣠⣴⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠻⣿⣿⣿⣤⣀⠈⠉⢀⣤⣶⣿⣿⣿⠿⠟⠁⣰⣿⣿⡟⠋⠁⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁⣠⣼⣿⣿⡟⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏⠈⠙⠛⠿⠿⠿⠿⠛⠉›Ask Codex to do anything?forshortcuts[0 q [?25h[?2026l[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l/Users/xxxxxx/orca-lanes/codex-plan-scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;codex-plan-scratch[?2026h[?25lGPT-6.1-Sol default·/Users/xxxxxx/orca-lanes/codex-plan-scratch[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ codex-plan-scratch[?2026h[?25h[?2026l]0;⠹ codex-plan-scratch]0;⠸ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ codex-plan-scratch]0;⠴ codex-plan-scratch]0;⠦ codex-plan-scratch]0;⠧ codex-plan-scratch]0;⠇ codex-plan-scratch]0;⠏ codex-plan-scratch]0;codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l› /plan switch to Plan mode /plan[?25h[?2026l[?2026h[?25l• Modelchangedtogpt-6.1-solmediumforPlanmode. Ask Codex to do anythingmdim · /Users/xxxxx/orca-lanes/codex-plan-scratch Plan mode(shift+tabtocycle)?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25lPlan adding one line to notes.md.Twostepsmax,asknoquestions.[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l› Planaddingonelinetonotes.md.Twostepsmax,asknoquestions.Ask Codex to do anything?forshortcuts[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ codex-plan-scratch[?2026h[?25l•Working(0s • esc to interrupt)  Plan mode[?25h[?2026l]0;⠋ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | codex-plan-scratch[?2026h[?25l·⠙[?25h[?2026l]0;⠹ ⠹ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l1[?25h[?2026l]0;⠋ ⠋ | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ ⠙ | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ ⠹ | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ ⠸ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ ⠼ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ ⠴ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ ⠦ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ ⠧ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ ⠇ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ ⠏ | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l• I’ll [?25h[?2026l[?2026h[?25lcheck[?25h[?2026l[?2026h[?25l`[?25h[?2026l]0;⠋ ⠋ | codex-plan-scratch[?2026h[?25l⠋[?25h[?2026l[?2026h[?25lnotes.md[?25h[?2026l]0;⠙ ⠙ | codex-plan-scratch[?2026h[?25l⠙[?25h[?2026l]0;⠹ ⠹ | codex-plan-scratch[?2026h[?25landtherepository[?25h[?2026l[?2026h[?25lguidance,thengive[?25h[?2026l]0;⠹ codex-plan-scratch]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25lPlan notes.md addition[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25laplanwith[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25lnomore[?25h[?2026l[?2026h[?25lthan[?25h[?2026l[?2026h[?25ltwo[?25h[?2026l[?2026h[?25lsteps[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25l [?25h[?2026l[?2026h[?25l• I’llchecknotes.mdandtherepositoryguidance,thengiveaplanwithnomorethantwosteps.[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch]0;⠧ Plan notes.md addition | codex-plan-scratch]0;⠇ Plan notes.md addition | codex-plan-scratch]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•Working(2s • esc to interrupt)[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l3[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l4[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•Ranpwd; rg --files -g 'notes.md' -g 'AGENTS.md' -g '!node_modules' -g '!vendor' └ /Users/xxxxxx/orca-lanes/codex-plan-scratch notes.md•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l5[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l6[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l7[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•Explored └ Readnotes.md + Show details [?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l8[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l•[?25h[?2026l[?2026h[?25l9[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l•[?25h[?2026l[?2026h[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25h[?2026l[?2026h[?25l• Proposed Plan1 [?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25l1.[?25h[?2026l[?2026h[?25lAppend[?25h[?2026l[?2026h[?25l`[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25lOne[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25lline[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch]0;⠸ Plan notes.md addition | codex-plan-scratch[?2026h[?25ladded[?25h[?2026l[?2026h[?25lOne line added.beneath[?25h[?2026l[?2026h[?25ltheexisting`#[?25h[?2026l]0;⠼ Plan notes.md addition | codex-plan-scratch[?2026h[?25l# scratchheadingin[?25h[?2026l[?2026h[?25l`notes.md[?25h[?2026l]0;⠴ Plan notes.md addition | codex-plan-scratch]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25lnotes.md,[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch]0;⠇ Plan notes.md addition | codex-plan-scratch[?2026h[?25lusing[?25h[?2026l]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25lthis[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25ltext[?25h[?2026l[?2026h[?25lasthedefault[?25h[?2026l]0;⠹ Plan notes.md addition | codex-plan-scratch[?2026h[?25lsince[?25h[?2026l]0;⠸ Plan notes.md addition | codex-plan-scratch]0;⠼ Plan notes.md addition | codex-plan-scratch]0;⠴ Plan notes.md addition | codex-plan-scratch]0;⠦ Plan notes.md addition | codex-plan-scratch[?2026h[?25l• Proposed Plan 2.[?25h[?2026l[?2026h[?25l • Proposed Plan1. AppendOne line added.beneaththeexisting# scratchheadinginnotes.md,usingthistextasthedefaultsince2.[?25h[?2026l[?2026h[?25l nonewasspecified.2.[?25h[?2026l]0;⠧ Plan notes.md addition | codex-plan-scratch[?2026h[?25lCheck[?25h[?2026l[?2026h[?25lthediffconfirmsexactlyoneaddedline[?25h[?2026l]0;⠇ Plan notes.md addition | codex-plan-scratch]0;⠏ Plan notes.md addition | codex-plan-scratch[?2026h[?25l[?25h[?2026l[?2026h[?25h[?2026l[?2026h[?25l2. Checkthediffconfirmsexactlyoneaddedlineandnootherchanges.[?25h[?2026l]0;⠋ Plan notes.md addition | codex-plan-scratch]0;⠙ Plan notes.md addition | codex-plan-scratch[?2026h[?25l2. Checkthediffconfirmsexactlyoneaddedlineandnootherchanges.•Working(12s • esc to interrupt)[?25h[?2026l[?2026h[?25h[?2026l]0;Plan notes.md addition | codex-plan-scratch[?2026h[?25l/Users/xxxxxx/orca-lanes/codex-plan-scratchHello, world. Hello, you.• Model changed to gpt-6.1-solmediumforPlanmode. › Planaddingonelinetonotes.md.Twostepsmax,asknoquestions. • I’llchecknotes.mdandtherepositoryguidance,thengiveaplanwithnomorethantwosteps.• Ran pwd; rg --files -g 'notes.md' -g 'AGENTS.md' -g '!node_modules' -g '!vendor' └ /Users/xxxxxx/orca-lanes/codex-plan-scratch notes.md • Explored └ Readnotes.md + Show details  •Proposed Plan 1. AppendOne line added.beneaththeexisting# scratchheadinginnotes.md,usingthistextasthedefaultsincenonewasspecified.2. Check the diff confirmsexactlyoneaddedlineandnootherchanges. Worked for 12s • 1:45 AM Implement this plan? › 1. Yes, implement this plan Switch to Default and start coding  2. Yes, clear context andimplementStart a fresh thread (current context: 2% used)3.No,stayinPlanmodeContinue planning with the modelenter select · esc back [?2026l[?2026h>_ OpenAI Codex (v0.160.0) /Users/xxxxxx/orca-lanes/codex-plan-scratch Hello, world. Hello, you.• Modelchangedtogpt-6.1-solmediumforPlanmode. › Planaddingonelinetonotes.md.Twostepsmax,asknoquestions. • I’ll check notes.md and the repository guidance, then give a plan with no more thantwosteps. • Ran pwd; rg --files -g 'notes.md' -g 'AGENTS.md' -g '!node_modules' -g '!vendor' └ /Users/xxxxxx/orca-lanes/codex-plan-scratch notes.md • Explored └ Readnotes.md  + Show details • Proposed Plan1. AppendOne line added.beneaththeexisting# scratchheadinginnotes.md,usingthistextasthedefaultsince none was specified.2. Checkthediffconfirmsexactlyoneaddedlineandnootherchanges.  Worked for 12s • 1:45 AM  ›Ask Codex to do anythingGPT-6.1-Sol medium·/Users/xxxxxx/orca-lanes/codex-plan-scratch·Plan notes.md additionPlan mode? for shortcuts[?25h[?2026l \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/omp-18-composer-narrow.meta.json b/src/main/runtime/__fixtures__/omp-18-composer-narrow.meta.json new file mode 100644 index 00000000000..6c8c03b0465 --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer-narrow.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-10-01T05:58:52.162Z", + "platform": "darwin", + "command": [ + "/bin/zsh", + "-f", + "-c", + "cd && exec /dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk" + ], + "cols": 60, + "rows": 24, + "note": "OMP 18.4.5; isolated HOME; no credentials; previously skipped setup; narrow 60x24 composer; updates disabled; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements", + "exitCode": 129, + "sends": [] +} diff --git a/src/main/runtime/__fixtures__/omp-18-composer-narrow.txt b/src/main/runtime/__fixtures__/omp-18-composer-narrow.txt new file mode 100644 index 00000000000..f2f013a4f6b --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer-narrow.txt @@ -0,0 +1,70 @@ +[?25l[?25l[?7l +╭─── omp v18.4.5 ────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│  │ │ +│  │ │ +│ │ │ +╰───────────────────┴────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine +  pulls live tokens over the wire — refresh keys never +  leave the host; `omp auth-gateway` fronts it as a +  drop-in proxy any OpenAI-compatible client can hit + + + +╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?2004h[?1l>[?u]11;?_25a1;s\_tsp;q;{"q":"hello","v":[1],"app":"omp"}\[?2031h[?2026$p[?2048$p[?2031$p[?2004$p[?1010$p[?1011$p[?2;1;0S[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[>4;2m[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h]0;π >]0;π > capture-cwd[0 q[?25l[?7l +╭─── omp v18.4.5 ────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰───────────────────┴────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine +  pulls live tokens over the wire — refresh keys never +  leave the host; `omp auth-gateway` fronts it as a +  drop-in proxy any OpenAI-compatible client can hit + + + π > ⬢ no-model > 📁 …XXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ … +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │  environment variable. Then use /model to select a model.    π > ⬢ no-model > 📁 …XXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ … ╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l +╭─── omp v18.4.5 ────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰───────────────────┴────────────────────────────────────╯ + Tip: Run `omp auth-broker serve` once and every machine +  pulls live tokens over the wire — refresh keys never +  leave the host; `omp auth-gateway` fronts it as a +  drop-in proxy any OpenAI-compatible client can hit + + + Warning: No models available. Use /login or set an API key  + environment variable. Then use /model to select a model.  + + π > ⬢ no-model > 📁 …XXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ … +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l[?25h[?7h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/omp-18-composer.meta.json b/src/main/runtime/__fixtures__/omp-18-composer.meta.json new file mode 100644 index 00000000000..8b68489410d --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer.meta.json @@ -0,0 +1,36 @@ +{ + "capturedAt": "2026-10-01T05:52:51.152Z", + "platform": "darwin", + "command": [ + "/bin/zsh", + "-f", + "-c", + "cd && exec /dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk" + ], + "cols": 120, + "rows": 40, + "note": "OMP 18.4.5; isolated HOME; no credentials; all five optional setup steps skipped with Esc; updates disabled; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements", + "exitCode": 129, + "sends": [ + { + "atMs": 12000, + "text": "Esc" + }, + { + "atMs": 14000, + "text": "Esc" + }, + { + "atMs": 16000, + "text": "Esc" + }, + { + "atMs": 18000, + "text": "Esc" + }, + { + "atMs": 20000, + "text": "Esc" + } + ] +} diff --git a/src/main/runtime/__fixtures__/omp-18-composer.txt b/src/main/runtime/__fixtures__/omp-18-composer.txt new file mode 100644 index 00000000000..e7e7b97965a --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-composer.txt @@ -0,0 +1,78 @@ +[?25l[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│  │ │ +│  │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Find out which model you emotionally abuse the most with `omp stats` + + + +╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?2004h[?1l>[?u]11;?_25a1;s\_tsp;q;{"q":"hello","v":[1],"app":"omp"}\[?2031h[?2026$p[?2048$p[?2031$p[?2004$p[?1010$p[?1011$p[?2;1;0S[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[>4;2m[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h]0;π >]0;π > capture-cwd[0 q[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Find out which model you emotionally abuse the most with `omp stats` + + + π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶──────────────────────────────────────────── +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?1049h[>4;2m[?1000h[?1003h[?1006h[?25l[?25l[?7l✦ · ✧ ✦  + ✧  + · ✧ ✧ ·  + ✦ ✦ · ✧  +· ✦ ✦ ✧  +  + ████████████████████████ ✦  + · ████████████████████████ ✦  + ✦ ✦ ████ ████ ✦  + ████ ████ · ✧  + · ✧ ████ · ████ ✧  + · ✧ ████ ████ ✧  + ✦ ▒▒▒▒ ████ ✦  + ▒▒▒▒ ████ ✧  + ✧ ████  + ████  + ░ ░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░▒▓▓███████▓████████▓▒░ ░░▒▒▒▒▒▓▓▒▓▒▒▒▒░░ ░  + ░ ░░▒▒▒▒▓▓▓▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░░  + ░░▒▒▒▓▓▓▒▒▒░░░ ░░▒▒▓█████████████▓▒▒░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░ ░ ░  + ░░▒▒▒▒▓▓▒▒▒░░ ░░░░▒▒▓████████████▓▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░  + ░░▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░  + ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░  + ░░░▒▒▒▒░░░░░ ░░░░░▒▒░░▒░░░░░░░░▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░░ ░  + ░ ░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░ ░░░░▒░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░  + ░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒▒░░░░░░░▒░░▒▒▒▒▒░░ ░░  + ░ ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░ ░░▒▒▒▒▒░▒░░░░▒░▒▒▒▒▒░░░ ░  + ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒░░▒▒▒░░░░░░  + ░░▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░ ░░ ░▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░░  + ░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░  + ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▓▓███▓▓▓▓▒▒░▒░░░▒░▒░░░░░  + ░░░░░░░░░░░░░▒▒▓▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░  + ░ ░░░░░░░░▒▒▒▒▓▓▓████▓▓▓▒▒░░░░░░░░░░░░░░  + ░░ ░ ░ ░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░  + ░░░░░░░ ░░░░░░░▒▒▒▒▒▒░░░  + ░ ░░ ░░░░░ ░░░ ░░░ ░░░░  + ░ ░░ ░ ░  + ░ ░  + ░░░ ░ ░ ░ ░░ ░ ░ ░░  + ░ ░░░░░░░ ░░░░░ ░░░░ ░ ░ ░░░░░░ ░  + ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ✧ ████   ████   ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ▒▒▓▓██████▓▓████████▓▒░░ ░░▒▒▒▒▒▓▒▓▒▒▒▒▒░░   ░ ░░▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░   ░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓██████████████▓▓▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░▒▒▒▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░   ░▒▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓████████▓▓▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░ ░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░   ░░░▒▒▒▒░░░░ ░░░░▒▒░▒▒░░░░░░▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░ ░   ░ ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░░░░▒░░░░░░░░▒░▒▒▒▒▒░░ ░ ░░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░▒░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░ ░░▒▒▓▓▒▒▒▒▒░▒░░░░░░ ░░░▒▒▒▒░▒░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░░   ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▒▓▓▓▓▓▓▓▓▒▒░▒░░░░░▒▒░░░░   ░░░░░░░░░░░░░░▒▒▓▓▓▓██▓███▓▓▓▒▒▒▒░░░░░░░░░░░   ░░░░░░░░▒▒▒▓▓▓██████▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░ ░░░ ░░░░░   ░ ░░░ ░ ░   ░   ░ ░ ░░   ░ ░░░ ░ ░░░░░ ░░ ░ ░ ░ ░ ░░   ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ▒▒▒▒ ████ ✧   ░ ░░▒▒▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▓███████▓▒▓████████▓▒░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░░▒▓█████████████████▓▒░ ░░░░▒▒▒▒▒▒▓▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓███████████████▓▒▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░░▒▒▓▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▓▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓▓████████▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░   ░░░▒▒▒▒░░░ ░░░▒▒▒▒▒░░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░░ ░   ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░ ░░░░░░░░░░░░░▒░▒▒▒▒▒░░ ░░   ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░ ░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░ ░░▒▒░▒░░░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▒▓▒▒▒▒▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▓▓▒▓▒▒▒░░░░░░░░░░░▒░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░ ░   ░░░░░░░░░░░░░░░░▒▒▓▓▓▓█▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░ ░   ░░░░░░░░░░▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░░░░░░░   ░░░ ░   ░ ░ ░   ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ▒▒▒▒ ████ ✧   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓██████▓▓▒▓████████▓▒░░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓████████▓████████▓▒▒░ ░░░▒▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▓▒▒░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒▒░░░ ░░▒▒▓▓█████████████▓▓▒░░░ ░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓███████████▓▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓██████▓▓▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░ ░   ░░░░░▒▒░░░░░ ░░░░░░░▒▒░░░░ ░░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░   ░░░░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░▒░░░░░░ ░░░▒░▒░░░░░░░░░▒▒▒▒░░░░░   ░░▒▒▒▓▓▒▒▒▒▒░▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░░▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▓▓▓██▓███▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░ ░ ░░░░   ░ ░░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░▒░░▒░░   ░░░ ░ ░   ░     ░ ░ ░░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▒▒▒████████▓▒▒░ ░░▒▒▒▒▒▒▒▒▓▒▒▒░░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓███████▓▓████████▓▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓████████████████▓▒▒░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒░░░░ ░░▒▒▓██████████████▓▓▒░░░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▓▒▒▒▒░ ░░░░▒▒▓▓████████████▓▒▒▒░░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░▒░░▒░░░░ ░░░░░░▒▒░░░░░░░░░▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░░░░▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒░░░░░░░░░░░░▒▒▒░▒░░░   ░░▒▒▒▓▒▒▒▒▒▒░▒░░░░░ ░░░▒▒▒░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░▒░▒░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓██▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░░░░   ░░ ░ ░░ ░░░░░░░░░░▒▒▒▒░░░   ░░░ ░ ░ ░ ░░   ░ ░ ░ ░ ░░   ░ ░░░░ ░░░░░░ ░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▓█████▓▓▒▒▒▒▓▓███████▓▒░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓████████▓▒▓████████▓▒░░ ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░▒▒▒▓████████████████▓▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▒▓▓████████████▓▒▒▒░░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓▓████████▓▒▒▒░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░░▒░░░ ░░░░░▒▒░░░░░░░▒▒▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░▒░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░▒░▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒░░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░░▒▒▒▒▒░░░░░░░░▒░░░░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░▒▒▒░▒░▒▒▒▓▒▓▓▓▒▒▒▒░░░░░░▒░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒▒░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░ ░░░░░   ░░ ░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░░ ░ ░░ ░░░░░     ░ ░   ░ ░░░░ ░ ░ ░░ press ⏎ to skip ░░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓████▓▓▒▒░░▒▓▓███████▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓███████▓▒▒▓▓███████▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒░▒░░░ ░▒▒▓▓████████████████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▓▓███████████████▓▒▒▒░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▓▓█████████████▓▓▒▒░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓██████████▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░   ░░░▒▒▒░▒░░░ ░░░░░▒▒░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░░▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░▒░▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░▒░░░░░░░░░░░░░░░▒░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░░░░▒▒░░░   ░░░▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░   ░░ ░ ░░░░░░░▒▒▒▒▒▒▒▒▒░░░   ░░░ ░ ░░░░░░░░░░   ░   ░ ░ ░ ░░ press ⏎ to skip ░░ ░ ░  [?7h[?25l[?7l ·   ✧ ✦ ✧   · ✧   ✧   ✦   ✧ · · ✧   ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ████   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░▒▓▓█▓█▓▓▒▒▒░░▒▒▓████████▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓█████▓▓▓▒▒▒▓████████▓▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░▒░░░░ ░░▒▒▓▓███████▓▓▓███████▓▓▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▒▒▒▒░░░ ░░▒▒▓▓████████████████▓▒▒░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒▒░   ░▒▒▒▓▒▒▒▒░░░ ░░░░▒▓▓█████████████▓▓▒▒▒░░░░ ░░░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░▒▒▒▓▓▒▒░░░ ░░░░▒▒▒▒▓▓██████████▓▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░░░▒░░░░░ ░░░░░░░░░░░ ░ ░░░ ░░░░░░░░░░░░░░░░▒▒░▒░░░░░   ░░░▒░░▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░▒░▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░░░░░░░░░░░░░░░▒░░░░   ░░▒▒▒▓▒▒▒▒▒▒░░░░░░ ░ ░░▒░▒▒▒░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▓▓▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▒▒▒▒▒░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░▒░░░   ░░░░░▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░   ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░░ ░ ░░░░░   ░░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░   ░░░ ░ ░░░░▒░▒▒░░   ░   ░░ press ⏎ to skip ░ ░ ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✦ · ████   ████   ░░░░▒▒░▒▒▒▒▒▒▒▒▒░░ ░ ░▒▒▒▓▓█▓▓▓▒▒▒░░░░▒▒▓███████▓▒▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▓████▓▓▓▒▒▒▒▒▓███████▓▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒▒░▒░░░░ ░░▒▒▓▓███████▓▓▓███████▓▓▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████████████▓▒▒▒░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░   ░▒▒▓▓▒▒▒▒░░░ ░░░░▒▓▓██████████████▓▒▒▒░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░▒▒▒▒▓▓▒▒░░ ░░░░▒▒▒▓▓███████████▓▓▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒▒░ ░░░▒▒▒▒▒▒▓▓▓██████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░▒▒▒░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░░░░░░░▒░░░░░░░   ░░░░▒░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░▒▒▒░░   ░░░░▒▒▒▒▒░░░░░░░░░░░░ ░░░▒░░░░░░░░░░░░░░░▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░ ░░░▒▒▒░░░░░░░░░░░░▒░░░░░   ░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓▒▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░   ░░░░▒░▒░▒░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░   ░ ░░░░░░▒▒▒▒▓▓▒▒▒▒▒▒░░   ░░░ ░ ░░░░░▒░▒▒▒░░░   ░     press ⏎ to skip ░ ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ░░░░░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░▒▒▓███████▓▒▒░ ░░▒░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒░░░▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓██████▓▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░▒▒░▒▒░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓█████▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░▒▒▒▓▓█████▓▓▓▓▓▓▓████▓▓▓▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓████████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░   ░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▓▓▓█████▓▓▓▒▒░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒░░░ ░░░░░░▒░▒░▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒░░░   ░░░░▒░▒░░░░ ░░░░░░░░ ░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░▒▒░░░   ░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒▒▒▒░░░░░░░░░░░░ ░░░░▒░░░░░ ░░░░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▒▒▒▒░░░░ ░░░░░▒░▒░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒▒▒▒░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░▒▒▒▒▓▒▓▓▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒▒░░ ░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒░░ ░ ░░   ░░░░░░░░░░░░   ░   press ⏎ to skip  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒░░▒▒░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓█████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░▒▒░░▒░░░░░░░░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▓▓█████▓▒▒▒░░ ░░░░░▒░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░▒░░░░░ ░ ░░░ ░░░▒▒▒▓▓████▓▓▓▓▒▓▓▓████▓▓▓▓▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░░▒▓▓██████████▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░░▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░▒▒░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒░░▒░░░░░░░░░░░░ ░░░░░░░░ ░ ░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░ ░░░░░░░▒░░░░   ░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░▒░░░░░░░░▒▒▒░▒░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▓▒▒▒▒▒░░░ ░ ░░░   ░░░░░░▒▒░░░░░   ░ ░░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓█▓▓█▓▓▒░ ░ ░░░▒▓▓███████▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░▒░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓██████▓▓▒░░ ░░░▒░▒▒▒▒▒▒▒░░░   ░░░░░░▒░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░░░░▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▓▓████▓▒▒▒▒▒▒▓▓▓██▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████▓▓▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░▒▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▒▒▒░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓████████▓█▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒▒░▒░░ ░░░░░░░░░░ ░░░░░▒░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░▒░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░▒░░░▒░░░░ ░░ ░░░░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░ ░░░   ░░░▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░░░  [?7h[?25l[?7l   ✧ ✧ · ✦   ✧ ✧ ✦✧   · ✧   ✧   · ✦   ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓██▓█▓▒░░ ░░░ ░░▒▓███████▓▒▒░ ░░░░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒░░ ░░░▒▒▓▓█████▓▓▒░░ ░░░░░▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒░░▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓███████▓▓▓▓▓▓███▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▓▓▒▒░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓▓██████████▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░░░░ ░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒░▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░▒░░░░ ░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░▒░▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▒▓▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▓▓▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▓▒▒▒▒▒░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░▒░░ ░░   ░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░░░░░░░░░░░░░▒░▒▒▓▓███▓▓▓▒░ ░░░ ░░▒▒███████▓▓▒░ ░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒░ ░░░░▒▒▓█████▓▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░ ░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▒░▒░░   ░░░▒░░▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▓▒▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒░░░░░░░ ░░░ ░░▒▒▒▓▓▓█████▓▓▓▓▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒░▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓█████████████▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░▒▒░░   ░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓▓██████████▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓██▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒░░░ ░░░░▒░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░▒░░░ ░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░ ░░░░░░░ ░   ░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░░▒░░░░ ░ ░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░▒▒▒▒░▒░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒░▒▒▒▒░▒▒▒▒▒▒▒▓▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░ ░░░░░▒▒▓▒▓▓▓▓▓▒▓▓▓▓▒▒▒░░░ ░ ░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░ ░░   ░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░ ░   ░░░░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▒▓▓▓███▓▓▒▒░ ░░░ ░░▒▒▓██████▓▓▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░ ░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░▒▒▓▓█████▓▓▒▒░ ░░░░▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒░░░░░░░ ░░░░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒░░▒░░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓███████▓▓▓██▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒░░   ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓▓██████████▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒░░░ ░░░░▒░░░▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░▒░▒░░░ ░░░░░░░░░░ ░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░ ░░░░░▒▒▓▒▓▓▓▓▒▒▓▓▓▓▒▒▒░░░░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░░ ░ ░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░   ░░░░░▒░▒▒░░░░░   ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▓▓▓████▓▓▒▒ ░░░ ░░▒▓▓██████▓▓▒░ ░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░▒▒▓██████▓▒▒░░ ░░░░▒░▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓██▓▓▒▒▒░░░ ░ ░░░░░░▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓███████████▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██████▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▓▒▓▓▓▒▒▒▓▒▓▓▒▒▒░░░░░░ ░░░░░░░   ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▒▒▒▒░░ ░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░   ░░░░░▒▒▒▒░░░░░░   ░░ ░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ████   ░░░░░░░░░ ░░░ ░░░▒▒▓▓█████▓▓▒░ ▒░░ ░░▒▓▓██████▓▒░░ ░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░▒▓▓█████▓▒▒░░ ░░░░░░▒▒▒▒▒▒░░░░   ░░░░░░ ░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▓▓█▓▓▓▓▓▒░░░ ░░░░░░░░▒▒▒▒▒░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░▒░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒░░░░░ ░ ░░░░░░▒▒▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▓██████████▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓██████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░   ░░░░░▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒░▒░░░░ ░░░░░░░░   ░ ░ ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▓▒▒▒░░░░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░   ░░░▒▒▒▒▒▒░▒▒░░░░ ░   ░░░░░░░ ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░ ░░░ ░░░▒▒▓██████▓▓▒░ ░▒░░ ░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒░▒░░   ░░░░░░░ ░░░░░░░░▒▒▒▓▓███▓▓▒▒░ ░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░░   ░░░░░ ░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░▒▒▒░▒░░░░   ░░░░░░░ ░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒░▒░▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░░   ░░░▒▒░░░░░░░ ░ ░ ░░░░░░░▒▒▒▓▓▓▓██▓█▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░▒▒▒▓▓████████▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░ ░ ░ ░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░▒▒░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒░░░░░ ░ ░░░░▒░░░░░░   ░░▒░▒▒▒▒░░░░░░░░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░░ ░░░░░░░░░░   ░ ░ ░ ░ ░ ░░░░░▒▒▒▒▓▓▒▓▒▓▓▒▓▒▒▒▒░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░   ░░░░▒▒▒▒▒▒░▒▒░░░░░ ░   ░░░░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░ ░░▒▒▓██████▓▓▒░ ░▒▒▒░ ░░▒▒▒▓██████▓▒░░ ░░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░░▒▒▒▓█████▓▒▒░░ ░░▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░▒▒░▒░▒░░░   ░░░░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░ ░░░░▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░░░░░ ░ ░░░░░░░░░░░░▒░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▒▒▒▒░░░░░▒░▒░▒▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░   ░░░░░▒░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░░░░   ░░▒▒▒░░░░░░ ░░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░░ ░ ░░░░░░   ░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▓▒▓▒▒▒▒▒░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░▒░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░ ░░ ░░░░▒░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░░▒░░░░░  [?7h[?25l[?7l · ✧     ✦ ✧ ·     · · ·   ✧   · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░░░░░░░ ░░▒▓▓██████▓▒▒░ ░▒▒▒▒░░ ░ ░▒▒▓▓█████▓▒▒░░ ░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░▒▒▓▓█████▓▒▒░ ░▒▒░░░░░░▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░░░▒▒░▒░▒░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒░ ░░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░░   ░░░▒░▒░░░░░ ░░ ░░░░▒▒▒▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒░░░░ ░░░░░░░░░░░░░▒▒░▒▒░▒▒░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░ ░░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░ ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░ ░░▒▒▒▒▒░░░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒░ ░▒▓▒▒░░ ░ ░▒▒▒▓█████▓▓▒▒░ ░░░░░▒▒▒░▒░░░   ░░░░░░░░░ ░░░▒▒▒▓▓█████▓▒░░ ░░▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒░░ ░░░░▒▒░░░▒▒░░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓█▓▓▒▒░ ░▒░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░ ░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░▒░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓██▓▓▓▓▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░ ░░░░░░   ░░▒▒▒▒▒░░░ ░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░ ░   ░░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░░░░▒░░░░░ ░░░░▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░ ░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░▒▒░░▒░░▒░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░▒▒▒▒▒░░░░░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒ ░▒▓▒▒░░ ░ ░░▒▒▓█████▓▓▒▒░ ░░░░░░▒▒░▒░░░░   ░░░░░░░░ ░░░▒▒▓▓██████▓▒░░ ░░▒▒▒▒░░░░░▒▒▒▓▓█▓▓▓▓▒▒░░ ░░░░░▒░░░▒▒░░░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓██▓▓▒░ ░▒░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░ ░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░▒░░░░░▒░░░░ ░░░░░░░░   ░▒░▒░░▒░░ ░░░░░▒▒▒▓▓███████▓▓▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░░ ░ ░░░░░░░░ ░   ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░   ░░▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░░ ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░░░░▒▒▒▒▒▒▒░░▒░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ░░░░▒░░░░░░ ░▒▒▓███████▓▒░ ░▒▒▓▓▒▒░░░ ░░░▒▒▓█████▓▒▒░░ ░░░░░░▒░▒░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒▒░ ░░▒▒▒▒▒░░░░░░▒▒▒▓█▓▓█▓▒▒░░ ░░░░░░░░░▒▒░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓██▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░ ░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░   ░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░▒░▒▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▒▓▒▓▓▓▓▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒░░░░░░ ░ ░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░░▒▒▒░░░░ ░░░░▒▒▒▒▓▓▓███▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░░▒▒▒░░ ░░░░░░▒░▒▒▒▒▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░ ░ ░░░░░░░░▒░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒░░░░░░ ░ ░░░░░░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░ ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░   ░░░░▒▒▒▒▒▒▒░░▒▒░░░░ ░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ░░░░▒░░░░░░ ░▒▒▓███████▒▒░ ░▒▒▓▓▓▒░░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒░░ ░▒▒▒▓▒▒░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░░░░░░▒░░░   ░░░░░░░░░ ░░▒▒▒▓▓▓████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░▒▒▒▓▓▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░ ░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░▒▒▒▓▓█▓██▓▓▓▓▒▓▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░░▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒▒░▒░░░ ░░░░░   ░░▒░▒▒▒░░░░ ░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░▒░▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░   ░░░░░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░▒░▒░▒░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░▒░▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒▒░░░░░░ ░ ░░░░░░▒▒░░▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░░▒▒░░░░ ░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ░░░░▒░░░░░░ ░░▒▒███████▓▒░░ ░▒▒▓▓▓▓▒▒░░ ░░░░▒▓▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓██████▓▒░ ░▒▒▒▓▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░ ░░▒▒▒▓▓█████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒░░░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░░░░░░░░░░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░ ░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░░▒░▒▒░░░ ░░░░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒░░▒▒░▒▒░▒░░░░ ░░░░   ░░▒░▒▒▒░░░ ░░░▒▒▓▓▓███████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒░▒░░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░▒░░░░▒░░░░░ ░░░░░   ░░░▒░▒▒░░░ ░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ ░ ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░▒░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░ ░░░░░░░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░▒░░░░░░ ░▒▒▒██████▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░ ░░░░▒▒▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▓▒░ ▒▒▒▓▓▓▒▒▒░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ░▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░ ░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░▒▒░░░░░░ ░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒▓███████▓▓▓▓▒▒▒▓▓▓▓▒▓▒▒▒▒░▒▒▒░▒▒░▒░░░░ ░░░░   ░░▒░░▒▒░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░▒░░▒░▒░▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒░▒░░░░▒░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░ ░   ░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░░░░░░ ░ ░ ░░░ ░░ ░░░░░░░   ░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░ ░░ ░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░▒░░░░ ░ ░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒░▒░▒▒▒░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░   press ⏎ to skip ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░░░░░░░░ ░▒▒▓██████▓▓▒░░ ░▒▒▓▓▓▓▓▒▒░░░░░ ░░▒▓▓████▓▓▒▒░░ ░░░░░░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▒▒░ ░▒▒▓▓▓▓▓▒▒░░░░░░░▒▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░ ░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▒▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░ ░░░░░   ░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░ ░░░░░░░▒▒▒▓▓▓████▓▓▓▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░   ░░▒░░░░░░░ ░░░▒▓▓▓███████▓▓▓▒▓▒▓▓▓▓▓▒▒▒▒░▒░░▒░▒▒▒░░░░ ░░ ░░░   ░░░▒░▒░░░ ░░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░▒░░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░░░ ░   ░░░▒▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░ ░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░▒░▒▒░░░░░░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒░▒░░▒▒░▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░   ░ press ⏎ to skip ░ ░  [?7h[?25l[?7l ✦ · ✧ ·   ✦ ·   · ✧   ·     ✧ ✦ ✧   ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ░░░░░░░░░░░░ ░▒▒▓█████▓▓▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓█████▓▒░░ ░░▒▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░▒▒▒▓▓█████▓▒▒░ ░▒▒▓▒▓▒▒░░░░░░░░▒▒▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░░▒▒▒░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░ ░░░░░   ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▓▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░░░░   ░░▒░░░░░░░ ░ ░░░▒▓▓▓███████▓▓▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░ ░   ░░░▒░▒░░░ ░░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░▒░░▒▒░░░ ░░░░░ ░   ░░░▒▒▒░░░ ░░░░░▒▒▒▓▓▓███▓▓▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░▒░▒░▒░░░░░░░░░ ░ ░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░ ░░ ░░ ░ ░░░░░ ░░░░░░   ░ ░░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░ ░ ░░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░▒░░▒▒▒▒░░░░░░░ ░░░░░░░░░░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░░░   ░ press ⏎ to skip ░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ▒▒▒▒ ████ ·   ░░░░░░░░░░░░ ░▒▓▓█████▓▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓█████▓▒▒░ ░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓████▓▓▒░ ░░▒▓▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░   ░░░░░░░░ ░ ░▒▒▒▓▓████▓▓▒░░ ░░▒▒▓▓▓▒▒▒░░░░░░░▒▒▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒▒░ ░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░ ░░░░░░░░   ░░░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░ ░▒░▒░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒░░░░░░░░▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░░ ░░ ░░░   ░░░░░ ░░░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░▒░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░░░   ░░░░░░░░░ ░ ░░░▒▓▓▓█████▓▓▓▓▒▒▒▒▓▒▒▒▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░ ░   ░░░▒░▒░░░ ░░░░▒▒▒▓▓███████▓▓▓▒▓▓▓▓▓▓▒▒▒▒░▒░░▒░▒▒▒▒░░ ░░░░ ░   ░░░░▒░░░░ ░░░░▒▒▒▒▓▓▓███▓▓▓▒▒▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░░ ░░░░░░░░░░▒░▒▒▒░▒░▒▒░░░░░░░ ░ ░░░░░░ ░░░░░░ ░   ░░░░░░ ░░░░ ░ ░░░░░░ ░░ ░░░░░ ░░░░░░   ░ ░░ ░░░░░░░ ░░ ░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒▒▒▒░░░░░░░░ ░░ ░ ░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░░░░░░░░▒░░░▒▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░ ░ ░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░░░   ░░░ press ⏎ to skip ░░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▒▒░ ░ ░░░▒▒▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▒▓▓▓███▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▓▓▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░░░   ░░░░░░░░ ░ ░▒▒▒▓▓███▓▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░░ ░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒░▒░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░░ ░░░   ░░ ░░ ░░░░▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒░░░░░ ░░░   ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░░░░   ░░░░░░░ ░ ░░ ░░░░▒▒▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░ ░░ ░   ░░░▒░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▓▒▓▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░░░   ░░░░▒░░░░ ░░░░▒▒▓▓▓█████▓▓▓▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░░   ░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░   ░░░░▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░ ░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░ ░░░░░░░░░░░░░░ ░░░░░ ░░░░░░   ░ ░░ ░░░░░░ ░ ░ ░░░░░░░░   ░░░░░ ░ ░░░░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░ ░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░ ░░ ░ ░ ░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒░▒▒▒▒▒░▒▒▒▒▒░░░░░░ ░ ░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░░ ░░░░░░   ░░░ press ⏎ to skip ░░░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ░░░░░░░░░░░░ ░░▒▓▓█▓█▓▓▒▒▒░ ░ ░░▒▒▓▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▓▓▓███▓▓▒▒░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒░░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓████▓▓▒▒░░ ░░▒▒▒▓▓▓▓▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░░▒▒▒▒▒▒▒░▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░░ ░ ░░░▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒░░░░ ░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░ ░░░░   ░ ░░░░░░░ ░░ ░░ ░░░░▒▒▓▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░░ ░   ░░░░░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░░░░   ░░░░░▒░░░░ ░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░▒░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░ ░░░░░ ░ ░░░░░░   ░░░░░░ ░ ░░░░ ░ ░ ░ ░ ░░░░░░ ░   ░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░░░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒░░▒░▒░░▒░▒▒░▒▒▒▒▒░░░░░░ ░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░   ░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ░░░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓█▓▓▒▒▒▒░ ░░▒▒▓▓▓██▓▓▓▒░░ ░░░░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒▒▓▓▓▓█▓█▓▓▒▒░░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░ ░░ ░░░░░░░░░ ░   ░░░░░░░ ░░▒▒▒▓███▓▓▓▒▒░ ░▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░▒▒▒▒▓▒▒▒▒░░░ ░ ░ ░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░▒▒▒▓▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░ ░░░░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░ ░░░░▒▒▒▓▒▒▓▓▓▓▓▒▒░░ ░▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░ ░   ░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░   ░ ░░░░░ ░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▓▓██████▓▓▓▒▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░ ░░   ░░░░░▒░░░░ ░░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░░▒░░░ ░░░░░░░▒▒▒▒▒▓▒▒▒▒░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░   ░░░░░░ ░░░░ ░░░░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░░░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░ ░ ░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒░░▒░░▒▒▒▒░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ████   ░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓████▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▓▓██▓▓▓▓██▓▓▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓█▓▓▒▒▒░ ░░▒▒▒▓▓▓▓█▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▒▒▒░░░ ░ ░ ░░░░ ░   ░░░░░░░ ░ ░▒▒▒▒▓▓██▓▓▒▒░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░░░░▒▒▒▓▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒░░░░░░ ░░░░▒░░▒▒▒░░░▒░░░▒░░░░░ ░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░   ░ ░░░░░▒▒░▒▒░░░░▒▒▒▒▒▒▒▒░▒░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░ ░░   ░ ░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░░░ ░ ░░░░░▒▒▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▓▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓▓███▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▒▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░░░░▒▒▒▒▒▒░░░░▒░▒░░░░░░░░░░░░░░ ░░░░░░░   ░ ░░░░ ░░░░░ ░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░░ ░ ░░░░░░░░░   ░░ ░ ░░░░ ░░░░░ ░ ░ ░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░ ░░░ ░ ░░░░░░░▒▒░░░▒░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░░░░░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ████   ░░░░░░░░░░ ░░▒▒▒▓▒▓▒▒▒░░░░ ░░▒▒▓▓█████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓██▓▓▓▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓██▓▓▓▓██▓▓▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░▒▒▒▓▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓██▓▓▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░░▒░▒░░░▒░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░░▒▒░▒▒▒░░░▒▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░ ░░   ░ ░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░ ░ ░░░ ░░░░░▒▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░▒▒░▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░ ░ ░░░░░░░░░   ░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░ ░ ░ ░ ░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░  [?7h[?25l[?7l · ✦ · ✦ ✦ ·   ✦   ✧   ✧ ✧   ✦ ✦ ·   ✧   ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ░░░░░░░░░░ ░░░▒▒▓▓▒▓▒▒░░░░░ ░▒▒▓▓██████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▓██▓▓▓▒▒░ ░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓██▓▓▓▓███▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒░░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░▒▒░▒▒▒▒░░░░░░░░░░░░ ░░   ░░░░ ░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░░▒░▒▒▒░░░░▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░ ░░░░░░▒░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░░ ░░   ░ ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░░░░░ ░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░░ ░░   ░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒░░░░░░░ ░░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░ ░░ ░ ░░░░ ░░░░ ░░░░░░░░░ ░   ░░░░░░░░░░░ ░ ░░ ░ ░ ░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░░░░░░ ░░ ░░▒▒▒▒▓▒▒▒▒░░ ░░░▒▒▓▓█████▓▓▒▓▓▓▓▓▓▓▓▒░░ ░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░ ░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓████▓▓▓▓███▓▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▒▓▓▓▓▒▒▒░░ ░ ░░▒▒▒▓▓▓▓▓▓▓███▓▓▓▒▒░░ ░ ░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒░░░ ░ ░░░░░░▒▒▒▒░░░░░░░░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░▒░░▒▒░░░░▒░░▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒░░░░░░░░░░░░▒▒▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░ ░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒░▒░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░ ░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░▒░░░ ░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓█▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░ ░ ░░░ ░░░░░▒░▒░░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░░ ░ ░ ░ ░ ░░░ ░ ░░░░░░░   ░░░ ░░ ░ ░░░░░░░   ░░░░░ ░░ ░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░ ░░░░ ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓▓▓▓▓▓▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▓▓██████▓▒▓▓███▓▓▓▒▒░░ ░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓██▓▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒░░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░ ░ ░░░▒▒▒▒▒▒░▒░░░░ ░ ░ ░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░ ░░░░░░▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒░░ ░ ░░░░░▒▒▒▒░░░░░░░░░░░░░   ░░░░ ░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░ ░░░▒▒▒▒░░░░ ░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░ ░░░░░░░░▒░░░░▒░░▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░░░░░▒▒▒░░▒░░░▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░▒░▒▒▒░▒▒░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░ ░ ░░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░░░ ░ ░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░░▒▒▒▒▒▒░░░░░░░░▒░░░░░ ░░ ░ ░ ░░░░░░   ░░░ ░ ░ ░░ ░░░░░░░ ░ ░░░░░░░░   ░░ ░░ ░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░ ░░░░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   ░░ ░░░░ ░ ░░▒▒▒▒▒▒▒▒░░ ░░░▒▓███████▓▓▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░ ░░░░░   ░░ ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓██▓▓▓▒▒░░ ░ ░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░ ░ ░░▒▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓█▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░ ░░ ░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒░░ ░▒▒▓▒▒▓▓▓▓▓▓█▓▓▓▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▓▓▓▒▒▒░░ ░ ░░░░░▒▒▒░▒░░░░░░░░░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▒▒▒░░░ ░░░░░▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░░░▒░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░▒░░ ░░░░░░░░░░░░▒░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒░▒▒░░░▒▒░▒▒▒░░░░░░░░░▒▒░░░░░░░░░░░▒▒▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░░░░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░ ░   ░░░░░░░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░▒▒▒░░░░░░░░░░ ░ ░ ░░░░░░   ░░░░░░ ░ ░░ ░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░   ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░ ░ ░ ░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░░░░ ░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▓▓███████▓▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░   ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓███████▓▓▒▓▓▓▓██▓▓▓▒▒░ ░ ░░▒▒▒▓▓▓▒▒▒░░░ ░░░░░ ░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓█▓▓▓▓▓▓▓███▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░ ░ ░   ░░░░░ ░░░▒▒▒▒▓▒▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓██▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒░▒░░░ ░░░░░░░░░░▒░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░░▒▒░▒░░▒▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░░░▒▒░░▒░░░░░ ░░░░▒▒░░░░ ░░░░░░░▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░ ░ ░ ░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░ ░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒░▒░░░░░░░░ ░░░ ░░░░░░   ░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░░░ ░ ░░░░░░░░   ░ ░░ ░░░░ ░ ░░░░░░   ░ ░░ ░ ░░ ░░░░░░ ░   ░░░░░░ ░░░ ░ ░░░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░░ ░░ ░ ░░░░░░░   ░░░░░░░░░░ ░░░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ████   ░░░ ░ ░░░░░▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▓▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▒░░ ░ ░░░░   ░░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▒▓███████▓▒▒▒▓▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▓▓▒▒▒▒░░ ░░ ░   ░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░ ░░▒▒▒▒▒▒▒▒▒░░ ░   ░ ░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▓▓▓▓██▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒▒▒░░ ░░░░░░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒░░░ ░░░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒░░░░ ░░░░░░▒░░▒░░▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░▒▒░░░░ ░░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░░░▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░▒░░   ░░ ░ ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░▒▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░   ░░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░░░░░░░ ░ ░ ░░░░░   ░░░ ░░ ░░░░ ░ ░░░░░░   ░ ░ ░ ░░░░░░ ░   ░░░ ░ ░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ████   ░░ ░ ░░░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▓▓▓▓▓▓▒▒░░ ░░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░ ░   ░░░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓█████▓▓▓▓▓▓▓▓███▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒░░░ ░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░░░ ░░░░░░▒▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▓▒▒▒▒░ ░▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░ ░▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒░░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░ ░░▒▒░░░░ ░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒▒▒▒░░░░░░░░▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░ ░   ░ ░ ░░░░░▒▒▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░ ░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▓▓▓▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░░░ ░ ░░ ░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░ ░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░ ░░░░░▒▒▒▒▒▒▒░ ░▒▒█████████▓▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░▒▒▒▓▒▒▒▒░░ ░   ░ ░░ ░░░░░▒▒▒▒▒░░░ ░░░▒▒▓▓█████▓▓▒▒▒▓▓▓▓██▓▓▓▒░░░░ ░░░░▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓▓████▓▒▒▒░░ ░░░▒▒▒▒▒░░░░░ ░ ░   ░░ ░ ░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░░░ ░░░░░░░▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒░░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░ ░░░░░░░░░▒░▒▒▒▒▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░ ░░░░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒░▒░░░░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░ ░   ░░░░░░ ░░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░░░ ░ ░ ░░░░░   ░░░░░ ░ ░░░ ░░░░░░░░ ░░░░░░░░   ░░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l · ✦   ·   · · ✧     ✦ ✦   ✦ ✧ ·   ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ░░ ░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░░░▒▒▒▒▒░░░ ░▒▒▓████████▓▒▒▒▒▒▓▓▓▓█▓▓▓▒░░ ░░░░▒▒▒▓▒▒▒▒░░░ ░   ░░ ░░ ░░░░░▒▒▒░░░░ ░░░▒▓▓██████▓▓▒▒▒▓▓▓▓██▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓███▓▓▓▓▒▓▓▓████▓▓▒▒░░ ░░░▒▒▒▒▒░░░░░░ ░ ░   ░░ ░░░░▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▓▒▒▓▒▓▓██▓▓▓▒▒░░░ ░░░░░▒░▒░░░░░░░░░ ░░   ░░ ░░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒▒▒▒▒▓▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒░▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░░░░░▒░░░▒▒▒▒▒▒▒▒▒▒░   ░░░░▒▒▒▒░░░▒▒▒░▒▒▒░░░░░ ░░░░░▒▒▒░░░ ░ ░░░░░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░ ░░ ░░ ░░░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░   ░░ ░░ ░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒░░░░░░░░ ░ ░░░░░░   ░░░░░ ░░░░░░ ░ ░░░░░░░░░ ░░░░░░░   ░░ ░ ░ ░░░ ░░░░░░   ░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░ ░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✧ ▒▒▒▒ ████ ✧   ░░ ░░▒▒▒▒▒▒▒▒▒░░ ▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░▒▒▒▓▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░░░░▒▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▓▓▓███▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▓▒▒▓▓▓████▓▓▓▒▒░░ ░░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒░░░ ░░ ░░▒▒▒▓▒▓▓▓▒▒▒▒▓▓███▓▓▓▒░░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░ ░░░▒░▒░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░░ ░░░░▒▒▒░░░ ░░░░░▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░▒░░▒▒▒▒░░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░   ░ ░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒░▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░▒░▒▒▒▒▒▒▒▒▒░▒░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░ ░ ░░░░░   ░░ ░░░░░░ ░░░░░░   ░ ░ ░ ░ ░░░░░░░   ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░ press ⏎ to skip ░░░ ░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ░░░░░▒▒▒▓▓▓▒▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░ ░ ░░░▒▒▒▒▒▒▒▒▒░░   ░░ ░░ ░░░░▒░░░▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░▒▒▒░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░ ░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░▒▒░▒▒▒▒░░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░ ░░░░░▒░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░ ░░░▒▒▒░░░ ░░░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░▒▒▒░▒▒▒░░░░░░░░▒▒▒▒░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░   ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░░   ░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░ ░░░░░   ░ ░░ ░░░░░░░ ░░░░░   ░ ░ ░░░░░░░   ░░░ ░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░ ░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░ ░   ░░░░░▒▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒▒░░▒░░░ ░▒▒▓████████▓▓▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░░░▒▒▒░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░░░░░░   ░░ ░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▒▒▒░ ░ ░░░░▒░░░░░░░░░ ░░░   ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒▒▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░▒▒▒░░░ ░ ░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░▒░▒░░▒░░░░░▒▒▒░░▒░░░░░░░░░▒▒▒▒░░░ ░░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒░░   ░ ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░   ░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░░ ░░░░░   ░░░░░░ ░░░░░▒▒░▒░░░░ ░░░░░▒░░░░░░ ░ ░░░░░   ░ ░░ ░░ ░ ░░░░░░░ ░ ░░░░░   ░ ░ ░ ░░░░░░   ░░░ ░░░░░░░   ░ ░ ░ ░ ░░ ░░░░░░░   ░░░░░░░░ ░ ░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░░░░░░░ ░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ████   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒░   ░░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒░▒▒▒▓▓▓██▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░▒▒▒░░▒░░ ░▒▒█████████▓▓▒▒▒▒▒▒▓▓▓███▓▒▒▒░░ ░░░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░▒▒░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▓▓███▓▓▓▒▒░░░ ░░░▒░▒░▒░░░░░░░░   ░ ░░ ░░░░░▒░░▒░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░░ ░░░ ░   ░░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▓▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░▒░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒░░ ░░░░░░░░░░▒░▒░▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▒░░ ░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒░▒░░▒░░░▒░▒▒▒░░░░░░░ ░░░▒▒▒▒░░░ ░░░░░░▒░░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░ ░░░░▒▒▒▓▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░▒░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░   ░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒░▒░░░░░░░ ░░░░░   ░░░░░░ ░░░░░░▒▒▒▒▒░░░ ░░░░░▒░░░░░░░░░ ░ ░░░░░   ░░ ░░░ ░░░░░░ ░░░░░░░░░ ░░░░   ░ ░ ░░ ░░░░░   ░░ ░░░░░░░   ░ ░ ░ ░░░░░░   ░░░░ ░ ░ ░ ░░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ████   ░░░▒▒▒▒▓▓▓▓▒▒░ ░▒▓████████▓▓▒▒▒▒▒░▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▓▒▒▒░░   ░░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒░▒▒▒▒▓▓▓█▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒░░░   ░░░░░░░░░▒▒▒░░▒░░ ░▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░▒░░▒░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▓▓████▓▓▒▒░░░ ░░░░▒░▒░░░░░░░░   ░ ░░░░░░░░░▒░░░░░░ ░░░▒▓▓▓██▓▓▓▓▒▒▒▒▓▓▓█████▓▒▒▒░░ ░░░░░░░░░░░░░ ░░░ ░   ░░░░░░░▒▒▒▒▒░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒▒▓▓▓▓██▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░░▒▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░░░░░░░   ░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▓▒▒▒▒░░ ░░░░░░░░░░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒░ ░░░░░░░░░░░░▒▒▒▒░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒░▒░░ ░░░░░░░░░▒░▒▒▒▒░░░░   ░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▒░░ ░░░░░░░░░░▒▒▒▒▒░░░   ░░░▒▒▒░▒▒▒░░▒▒▒░░░░░░ ░░░▒▒▒▒░░░ ░░░░▒░░░▒▒░▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░░░░ ░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒░▒░░░   ░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░ ░   ░░░░░ ░ ░ ░░▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒░░░░░░░░░ ░░░░░   ░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░   ░ ░░ ░░░░░░   ░ ░ ░ ░░░░░   ░ ░ ░ ░░░░░░   ░░░░ ░ ░ ░ ░ ░░░░░░░   ░ ░░░░░░░░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   ✦ ████· ✦   ████   ░░░▒▒▓▓▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒░░░ ░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░▒▒▒▓▓▓█▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▓▓█▓███▓▓▒░░ ░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▒▒▓█████▓▓▓▒▒▒▒▒▒▓▓████▓▓▒▒░░░ ░░░░░░░░░░░░ ░░░ ░   ░ ░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▓▓▓▓██▓█▓▒▒░░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░▒░▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒░░▒▒▒░░░ ░░░▒▒▒▒░░ ░ ░░░░░░▒▒░▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒░░░ ░░░░░▒▒▒▒░▒▒▒▒▒▒░▒░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▓▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░   ░░░░░░ ░░░░░░▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░ ░░░░░░ ░░░░░   ░ ░ ░░░░░░   ░░░░░   ░ ░░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✦ ✦ ✧   · ✦ ✦ ✦ ✧ · ✦   ✧ ✦ ✦ ✧ ✧ · · ·   ·   ✦ ✦   ✧ ✦   ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▒▓▓▓▓▓▒▒░ ░▒▓▓███████▓▓▓▒▒▒▒▒░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░▒░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░░▒░▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░░▒▒▒░░ ░░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒░░░ ░░░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░   ░░░░░░ ░░░░░▒▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░░ ░ ░░░░░░░ ░ ░░░░░░   ░ ░░ ░ ░░░░░   ░░░░░░   ░ ░ ░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▓▓▓▓▓▓▒▒░ ░▒▓▓██████▓▓▓▓▒▒▒▒▒░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▒▓▒▒▒▒░   ░░░░▒▒▓▓▒▒▒▒░░ ░▒▓███████▓▓▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒▒░░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▒░░░░   ░░░░░░░░▒░▒▒░░░░ ░▒▓▓████████▓▒▒▒▒▒░░▒▒▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░░ ░ ░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▒▒░░░ ░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░▒░▒░░░░░░░░ ░░▒▒▒▒░░░ ░░░▒▒▒░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒░▒▒░░░▒░▒▒▒▒▒▒░░ ░░░░▒▒▒░▒▒░▒▒▒░▒▒░░░   ░░░░░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░▒▒▓▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░ ░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░ ░░░░   ░░░░ ░ ░░░▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░ ░░ ░░   ░ ░░░░░ ░ ░░░░░░░░░ ░░░░░   ░ ░░░ ░░░░░   ░░░░░░   ░░░░   ░ ░ ░░ ░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▓▒░░ ░░▒▓▓█████▓▓▓▓▓▒▒▒▒▒░░░▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒░░░ ░▒▓███████▓▓▓▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░   ░░░░░░▒▒▒░▒▒░░░ ░▒▓█████████▓▒▒▒▒▒░░▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░░░ ░░░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▓▓▓████▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░░ ░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░ ░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░░▒▒▒░░░░   ░░░░▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒▒▒░▒░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒░▒░░▒░▒░░░░░░ ░░▒▒▒▒░░░ ░░░▒░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░▒▒▒░░░░░░░░░░▒▒▒▒▒▒░░ ░░░▒░▒░▒▒░▒▒▒░▒▒▒░░░   ░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░░░▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░ ░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░ ░ ░░▒▒▒▒▒▒▒░░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░   ░ ░ ░░░░ ░░░░░   ░░░ ░░░░░░   ░░░   ░ ░ ░░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓███▓▓▓▓▓▓▒▒▒▒▒░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░░ ░▒▓▓███████▓▓▓▒▒▒▒░░░░░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒░▒▒▒░░░░   ░░░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▒▒░░▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░ ░ ░░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░▒░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░▒▒▒░░░░   ░░░░░▒░▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░░░ ░░▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░▒░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░   ░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░   ░ ░ ░░░░░ ░░░░   ░ press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ░░▒▒▓▓▓▓▓▒▒░ ░░▒▒▓▓▓█▓▓▓▓▓▓▓▒▒▒▒▒▒░░░▒░▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░▒▒▒░░░░░   ░░░▒░░▒▒▒▒▒▒░░ ░▒▒▓████████▓▒▒▒▒▒▒░░░▒▒▒▓▓████▓▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░ ░░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▒▒▒░░ ░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░▒▒░░▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒░░ ░░░░░░░▒▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░▒▒░░ ░░░░░░░▒▒▒▒▒▒░▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░ ░ ░░░▒▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░ ░░░░▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒░░░░░░ ░   ░ ░ ░ ░░░░░░▒░░░░ ░░░░░░░░░░░░ ░ ░ ░   ░ ░ ░ ░ ░░░░░░ ░░░   ░░░░░ ░░░░░░   ░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ░▒▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓▓█▓▓▓▓▓▒▓▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓████████▓▒▒▒▒▒▒░░░░░░░▒▒▓▓▓█▓▓▓▒░░░░ ░░░░▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░▒▒▒▓▓███▓▓▒▒░░ ░░░░░░░░░░░   ░░░░░░░▒▒░░░░░░ ░░▒▓███████▓▓▓▒▒▒▒▒▒░▒▒▒▓▓█████▓▓▒░░ ░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒▒▒░░░ ░░░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▓▒▒▒▒░░ ░░░░░░░░▒░░▒░▒░░   ░░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒░▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒░░ ░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░░ ░░░▒░░▒░░ ░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░▒░▒▒░▒░░░░░░░░░░░░ ░░░░▒▒▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░▒▒░░▒░░░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░ ░ ░ ░   ░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░░░░ ░   ░ ░ ░░░░░ ░░░░░░░░ ░ ░░   ░░░░░ ░░░░░░   ░░   press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▓▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓███████▓▓▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░░▒▓████████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░   ░░░░▒░░▒▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒░▒░▒▒▒▒▓█████▓▓▒▒░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░ ░░░░░░░░░░░░░░   ░ ░ ░ ░░ ░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░ ░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░░ ░░▒░▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░▒▒▒▓▒▒▒▒▒░ ░░░░░░░▒░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░ ░░░░░▒░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░▒░░▒░░░ ░░░░░░░▒▒▒▒░▒░▒▒░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒▒░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒░░░▒░░░░ ░   ░ ░ ░ ░░░░░░ ░░░░░░░░░░ ░░   ░░░░░ ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓██████▓▓▒▒▒▒▒▒▒░░░░ ░░░▒▒▓▓█▓█▓▓▒▒░░ ░ ░░▒░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓███▓▓▓▒▒░░░ ░ ░░░░░░░░░   ░░░░▒░▒▒▒░░░░ ░▒▓████████▓▓▒▒▒▒▒░▒░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▓▓██████▓▓▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒░▒░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░ ░░░░░░░░░▒▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░▒░░▒▒░▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░░ ░░░░░░▒▒▒▒░▒▒░░░▒░░░   ░░░░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒░▒░▒░░░░ ░   ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░   ░░░░░░ ░░░░   ░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ✧   ✧ ✧ · · ✦✧   ✦   · ✦ ✧ ✦   · · · ·   · · ·✧ · ·   ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░▒▒▒▓▓▒▒▒░░ ░░░▒▒▒▓▓▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▓▒▒▒▒▒░░ ░░░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▓████████▓▒▒▒▒▒▒░░░░░▒▒▓▓▓███▓▓▓▒░░ ░ ░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░░░ ░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▓▒░░ ░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░▒░░░   ░░░░░░▒▒▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░░ ░░░░░░░░░▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░░▒▒░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░ ░░░░░▒▒▒▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░ ░░▒▒▒▓▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒▒▒░▒░░░░░░░   ░ ░ ░░░░░▒░░░ ░░░░░▒▒░░░░ ░   ░░░░░░░░ ░░   press ⏎ to skip ░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒░░ ░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░▒▒▓▓██████▓▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░ ░░░░░░░░░   ░ ░░░ ░ ░ ░░░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░▒▒░░░ ░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒░░░ ░░░░░░░░░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒░░░ ░░░░░░░▒░▒▒▒▒▒░░░░   ░░░░░░▒▒░░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░▒░▒░░░░   ░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒░▒░░░░░   ░ ░ ░░░░░░░▒░░░░ ░░░░░▒▒░░░░░░   ░░ ░░░░░░░░░ ░░   ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ████   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▒▒░░░ ░░▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░▒░░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░ ░ ░░░▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒░░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░▒▒▓██████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▒▓███████▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▒░░░ ░ ░ ░░░░░░░░░░   ░░░░░ ░ ░ ░░░░░░░ ░░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓█▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░░░░░░░▒░░▒░░▒▒▒▓▓▓▓▓▓▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▓▒▒░░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒░░░ ░ ░░░░░░▒▒░▒▒░░░   ░░░░▒░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░▒░░░░░ ░░░░▒░▒░░░ ░ ░░░░░▒░▒▒▒▒▒░░░░░   ░░░░░░▒▒▒░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░░▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒░░░░░░   ░ ░░░░░▒▒▒░░░░░ ░░░░░▒▒░░░░░░░   ░░░░░ ░░░░░░░░░ ░   ░░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▒▒▒▒░▒░░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░ ░ ░░▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░ ░░░░░▒▓▓▓▓▓▓▒▒░░░ ░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░ ░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓█▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓████▓▒▒░░░ ░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░ ░ ░░▒▒▓▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░ ░░ ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░░   ░░ ░░░░░░░▒░░░░ ░░░░░░░░░▒▒▒▒▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒░▒░░░   ░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░░░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒░▒░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒▒░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░▒▒░░░░░░ ░░░░░▒░▒▒░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒░▒░▒░░░░░░░   ░░░░░░░ ░░░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░░▒░░░░░░░░▒▒▒▒▒░▒░░░░ ░   ░ ░░▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░▒▒▒░▒░░░░░   ░ ░░░░░░░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░ ░░░▒▒▓▓▓▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░░▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▒▒▒▒░▒░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒░ ░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒░░░░ ░▒▒▓▓████▓▓▒▒░░ ░░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▒▒▒░░ ░ ░░░░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒░▒▒▒▓▓▓████▓▓▓▒▒░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▒▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░ ░░ ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░▒░░░░░   ░ ░ ░ ░░░░░░░░░░ ░░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒▒░▒░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒░░░ ░░░▒░▒▒░▒░▒░░▒░░░░   ░░░░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░░░░▒▒▒▒▒▒░░░░ ░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒░▒░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░░▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓█████▓▓▓▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓██▓▓▒▒░░ ░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓████▓▓▓▒▒░░ ░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░ ░ ░ ░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░░ ░░░░░▒▒░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░▒▒░░▒░░░░   ░░░░░░▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░ ░░░░░▒▒░▒░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░░░▒▒▒░░░░▒░▒░░░░   ░░ ░░░░▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░░░░░▒▒▒▒▒░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░ ░ ░░░░░░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   ░░░▒▒░▒▒░░ ░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░░▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒░░ ░ ░░▒▒▒▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓█▓▓▓▓▒▒░ ░ ░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░░▒▒▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓▓███▓▒▒▒░ ░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▓███████▓▓▓▒▒▒▒▒▒▒▒░░░▒▒▒▓▓████▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒▒░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒▒░ ░ ░░░░░░░░░ ░   ░ ░ ░ ░░░░░░ ░ ░░░░░▒░▒▒░░░░▒▒▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░░▒░▒░░░   ░░░░░░░░░░░▒░▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░░░░░░░░▒░░▒░░░░░   ░░░░░░░▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░▒▒▒▒▒░░ ░░░▒░▒░░░░▒░▒░░░░░   ░ ░░░░ ░░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒░░░░ ░ ░ ░░░░░░░▒▒░░░░   ░░░░░░░░ ░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l   ·   ✦ ✧ ✧· ✧   · ✦ ✧ ✦   ✧   ✦ · ✧   ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ░░░▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░▒▒▒▓▒▓▒▓▒▒▒░ ░░▒▒▒▒▒▒░░   ░░░░▒▒▒░░░ ░░░▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒▒░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒░░░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▒▒░░░ ░ ░ ░░   ░░░░░░░▒░░░░░░ ░▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▓████▓▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░ ░░░░░░░░░░   ░ ░ ░░░░░ ░░░░▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░▒░░▒░░░   ░░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒░░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░ ░░░▒░░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░▒▒░▒▒░░ ░░░▒░░░░░░▒░▒░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒░░░░ ░░ ░░ ░░░░░░░▒▒░░░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░▒░░░░░ ░░░▒▓▓▓▓▓▓▓▒▒▒░ ░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░▒▒▒▒▒▒▓▒▒▒░░ ░░░▒▒▒▒▒░░   ░░░░▒░░░░░ ░░░▒▒▒▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓▒▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░▒▒░░░░   ░░░▒▒░▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░▒▒▒▒░░░░ ░░▒▒▓▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓███▓▓▒▒▒░░ ░ ░ ░░   ░░░░░░░▒░░░░░ ░░▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▓▓███▓▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▓▓█████▓▓▒░░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▓▓████▓▓▓▒▒░ ░░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░░ ░ ░░░░░░░░░   ░ ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░   ░ ░ ░░ ░░░░▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒░░░   ░ ░ ░░░░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░ ░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░░▒░▒▒░░░░░ ░░░░░ ░░░▒░▒░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░ ░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░▒▒░░░░░ ░░░░░░░░░▒░▒░░░░░ ░   ░░ ░░░░░░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░   ░░░░░░▒▒▒▒▒▒░▒▒▒░▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░ ░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ████   ░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░▒░▒▒▒░░░   ░░░░░░░░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▒▓▓▓▒▒░░░░ ░░░▒▓▓▓▓▓▒▒▒░░ ░░░░▒▒░░░░░   ░░░▒░░▒░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░ ░   ░░░▒▒▒▒░░░ ░░▒▒▓▓▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓██▓▓▓▒▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓████▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▓▓███▓█▓▒▒░░ ░ ░ ░░░░   ░░░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░░   ░ ░░░░░░░░░ ░░▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░ ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒░ ░░░░░░░▒░░░░░░   ░░ ░░░░░░░░░░░░ ░ ░ ░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░░▒░░▒░░░░   ░░░░░░░░▒░░▒░░░░░ ░░░░░ ░░▒▒░▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░▒░░░░░ ░░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒░░▒▒▒░▒▒▒▒▒░▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░▒▒▒▒▒▒░░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░   press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ████   ░░░░░░░░░ ░░▒▓▓▓█▓▓▓▒▒▒░ ░░░▒▒▒▓▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░   ░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒░░░░░   ░░░░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓█▓▓▓▓▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓██▓█▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▓▓▒░░ ░░░░░░░░░   ░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░▒░░░░   ░ ░ ░░░░░ ░░░░░░░░░░░▒░▒▒▒▒▒▓▓▓▒▒▒▒░ ░░░░░░░░░░░░░   ░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░▒░░░░   ░░░░░░░░░░░░▒░░░░ ░░░ ░ ░░▒▒▒▒▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░▒▒░▒▒▒▒▒░▒░░░ ░ ░░▒░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░ ░░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░▒▒░░░░░ ░░░░▒░░░░░░   ░░ press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░▒░░▒░░░ ░░▒▒▒▒▒▒▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓█▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓████▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▓▓▓▓██▓▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓████▓▓▒▒░░░ ░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓█████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░▒░░░░   ░ ░ ░ ░ ░░░ ░░░░▒▒░░░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░   ░░ ░░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░▒░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒░░░░░ ░ ░ ░ ░░░░▒░░░░░░░   ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░░░░░░░ ░░▒▒▓█▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓▓▓▒▒▒░░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░░░░░ ░░░░▒▒▓▓▓▒▒▒░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░ ░ ░   ░░░░▒░░░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓█▓█▓▓▓▒▒░ ░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓███▓▓▒▒▒░ ░ ░░░░░░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒░▒░░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒▒░ ░░░░░░░   ░░░░░░░ ░░▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░ ░░░░░░░░░   ░░ ░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒░░░ ░░░░░░░░░▒░▒░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒░░░░░░░░   ░ ░░░░▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░   ░░░▒▒▒▒░░░░░ ░░░░ ░░ ░░░░▒░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░ ░░░░░░ ░▒▒▓▓███▓▓▓▒░░░ ░░▒▒▒▓███▓▓▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░░ ░░▒▓▓▓▓▓▓▓▓▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░▒▒▒▓▓▓▓▓▒░░░ ░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒▒░ ░░░▒▒▒▓▒▒▒▓▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓██▓▓▒▒░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▒▒▒░ ░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░   ░░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░   ░ ░░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒▒▓▓▒▒▒░░ ░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░ ░ ░░░░░░░▒▒░░ ░░░░░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░▒░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░░▒▒▒▒░▒░░░░░░   ░ ░ ░░░░▒░▒░░░░░▒▒░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░░░░░░░   ░░░░░░ press ⏎ to skip ░░░░░░░░  [?7h[?25l[?7l ░░░░░░░░░ ░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▒▒▒▒░░░░░░░░░▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░   ░░░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░ ░░░░░░   ░░░░░░░ ░▒▒▒▓▒▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓██▓▓▓▒▒░ ░   ░░░░░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▓▒▒░░ ░░░ ░░   ░░░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓█▓███▓▓▒▒░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▓▓▓▓████▓▒▒░░ ░░░░░░   ░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓███▓▒▒░░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░░░░   ░ ░░ ░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░░░░░░░░░░░░   ░░ ░░ ░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░   ░ ░░░░░░░░░▒░░░ ░ ░░░░░░░▒▒░░░ ░ ░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░░░░░▒▒░░░░░░ ░░░░░▒▒▒░▒░░░░░░   ░░ ░ ░░░░▒░▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░▒░▒░░░░░   ░░░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧   ✦ ✦ ✧     · · · ·   ✦ ✧ · ✧   · ████████████████████████ ✧   ████████████████████████ ✧   ✦ ✧ ✦ ████ ████ ·   ████ ████   · ✧████ ████ · ✧ ✦   ✧ · ████ ████   ✧ ▒▒▒▒ ████   ▒▒▒▒ ████ ✧   · ████   ░░░░░░░ ░░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▓▒▒▒░░░░░░░░░▒▒▒▓▒▓▒▒▒░░ ░░▒░▒░░   ░░░░ ░░▒▒▓▓██▓▓▓▒▒░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒░░ ░▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░ ░░▒▒▓▓▓▓▓▓▒░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▓▓▓▓▓▓▒▒░░░ ░░░░░░   ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓███▓▓▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▒▒▒░ ░░░░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▓▒▒░░ ░░░░░░   ░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░   ░ ░░░░▒▒░░░░░░░░▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░░ ░░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░░░░ ░░░▒▒▒░▒░░░░░░░░   ░░░ ░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░░▒░▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l ████████████   ██ ██   Setup step 1 of 5   ░░░░░░░ ░░▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░░▒░░░   ░░░ ░░▒▓▓▓██▓▓▓▒░░ ░░░▒▒▓▓▓▓██▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░ ░░▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒░▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░░   ░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒░░░░░ ░▒▒▓▓███▓▓▒▒░░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓█▓█▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓███▓▓▒▒▒░ ░░ ░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░░▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒░ ░░░░░░░░░░   ░ ░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░▒░▒▒▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░▒░░░░░░░ ░░░░▒▒░▒░░░░░░░░   ░░░░░ ░░░ ░░░░░░░░░░░░░░░░░░▒░▒▒▒░░░ ░░░░▒▒▒░▒▒░░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░▒▒▒▒░░░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░░░░░  [?7h[?25l[?7l ██ ██   ▒▒ ██   omp   Sign in to your providers   Sign in to one or more providers. Press ⎋ when you're done.   ░░░░░░░ ░▒▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒░░░░░░░░░░▒▒▒▓▒▒▒▒░░ ░░░░░░░   ░ ░░░ ░▒▒▓████▓▓▒▒░░ ░░░▒▒▓▓▓████▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▒▒▒▒░░ ░ ░░░░░░   ░ ░░░▒▓▓███▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒░░ ░ ░ ░   ░ ░░░▒▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░   ░ ░ ░ ░▒▒▒▒▓▓▒▒▒░░░ ░░▒▒▒▒▒▒▒░▒░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒░░░░░░ ░▒▒▒▓▓██▓▓▒▒░░   ░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒▒░░░░░ ░░▒▒▓▓██▓▓▓▒▒░░ ░░ ░   ░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░▒▒▓▓███▓▓▓▒░░░ ░ ░░   ░░░░░░░░░░░ ░▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░░▒▒▓████▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓█▓█▓▓▓▒▒░░ ░░░░░░░░   ░░░░░░ ░░▒▒▒▒▓▓▓▒▓▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▓▓▒▒▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░ ░░░ ░░░░░▒▒▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░ ░░░░░░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░░ ░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░ ░░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░▒░▒░░░░░░░░░   ░░░░░░░ ░░░ ░░░░░░░ ░ ░░░░░▒░░░░░░░ ░░▒▒▒░▒▒░░░░░░░   ░ ░░░░░░░░░░░░░░▒▒░▒▒░░░░░░ ░░░░▒▒▒▒▒░░░░░░   ░░░░░▒▒░░░▒░░▒░▒░▒▒░░░░░░ ░░ ░░░░▒▒▒▒░▒░░░░   ░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░░░░▒▒▒▒▒░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░▒░░░░  [?7h[?25l[?7l ██     Pick a provider to sign in — you can connect more than one.   │ ❯ ChatGPT Plus/Pro (Codex Subscription) █ │   │ Anthropic (Claude Pro/Max) │ │   ░░░░░ ░▒▓▓███▓▓▓▒▒░░ ░░░▒▒▓▓▓█████▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▒▒▒░░ ░░░░░░░   ░ ░░▒▒▓▓███▓▓▒░░ ░░░▒▒▒▓▓█▓▓▓▓▓▒▒▒░░ ░▒▒▓▓▓▓▓▒▒░░░ ░ ░ ░   ░░▒▒▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒▒▓▓▓▓▓▒▒░░ ░   ░ ░ ░░▒▒▒▓▓▓▒▒▒░ ░░▒▒▒▒▒▒▒░▒▒▒░░ ░▒▒▒▓▓▓▓▓▒▒░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒░░░░░░ ░░▒▒▓▓██▓▓▓▒░░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒░░░░░ ░░▒▓▓██▓▓▓▒▒░░ ░░ ░   ░░ ░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓▓██▓▓▓▒▒░░ ░ ░░   ░░░░░░░░░░░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░   ░░░░░░░░ ░░▒▒▓▓█▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▓▓▓██▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓█▓▓▓▒▒░░ ░░░░░░░   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░ ░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▓▓▒▒▒▒░░ ░░░░░░░░░░░   ░░░░░ ░░░ ░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░ ░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░ ░░░░░▒░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░░░░░░▒▒░░░░░░░   ░░░░░▒▒░░░▒░░▒░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒░░░  [?7h[?25l[?7l     ╭─ Select provider to login ───────────────────────────────────────────────────────────────────────────────────╮   │ Z.AI (GLM Coding Plan) │ │   │ Z.AI (GLM Coding Plan · Sign in) │ │   ░░░▒▓▓▓▓▓▓▓▒░░ ░▒▒▒▒▒▒▓▓▓▒▒▒▒▒▒░ ░▒▒▓▓▓▓▓▒▒▒░ ░   ░░▒▒▒▒▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░   ░ ░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▒▒▒▒░░░░░░░ ░▒▒▓▓███▓▓▒▒░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒░░░ ░░ ░   │ Devin │ │   ░ ░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▓▓██▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓▓▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓█▓██▓▓▒▒░░ ░ ░░░░░   ░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▒▒▒░░ ░░░░░░░   ░░░░░░ ░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░ ░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░ ░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░ ░░░░▒▒░░░░░░░   ░ ░░ ░ ░░░░░░░░░ ░░░░░░░▒░░░░░░ ░░░▒▒▒▒░░░░░░ ░░   ░░░░░░▒░░░░░░░░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒▒▒▒░░░ ░  [?7h[?25l[?7l │ Kimi Code │ │   │ OpenRouter │ │   │ GitHub Copilot │ │   │ Cursor (Claude, GPT, etc.) │ │   │ Antigravity (Gemini 3, Claude, GPT-OSS) │ │   │ Type to search │   ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░   ░ ░░░░░▒░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░ ░░░░░░░░░   ░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░ ░░░ ░░░░░░░░░   ░░ ░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░░ ░░░░░░░░░░░░░ ░░░▒░▒▒░░▒░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▒░░░░░░ ░░░░▒▒▒▒░▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░▒▒▒▒░▒░░░   ░░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒▒░░░░  [?7h[?25l[?7l     ░ ░░ ░░░▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░░░░░░░░   ░ ░░░░ ░░░░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░       ░░░░░░░░░░░░░ ░░░░ ░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░░░░░░ ░░░░▒▒░▒▒░░░░░░     ░░░░░▒▒▒▒░░░░░░░░▒░░░░░░ ░░░░▒▒▒▒▒▒░░░   ░░░░▒░▒▒░░ press ⏎ to skip ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l         ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░░░░░░░░░░ ░ ░░░▒▒░▒▒░░░░░░░░   ░░░░░▒▒░░░░░░░░░░▒░░░░░░ ░ ░░░░▒▒▒▒░░░░░   ↑/↓ select · ⏎ confirm · ⎋ skip · ⌃C exit setup  [?7h[?25l[?7l     ░░░░░░░░░░░░░░░░░▒░░░░░ ░░░░▒▒▒▒▒░░░░  [?7h[?25l[?7l  [?7h[?25l[?7l Setup step 2 of 5   Choose your default model   Search configured models and save the model used for new sessions.   Discovering available models…   🔍 >        No models available in this scope                      [?7h[?25l[?7l Type to search. ⏎ saves the highlighted model as your default.  [?7h[?25l[?7l Setup step 3 of 5   Choose glyph mode   Pick the row that renders cleanly in your terminal.   If a row shows boxes, tofu, or misaligned icons, pick another.   1 Nerd Font    󰉋  ╭─╮ ├─ ◆ ✔ ✖   ❯ 2 Unicode ✔ ✖ 📁 ⬢ ╭─╮ ├─ • ⠋ →   3 ASCII [ok] [x] > + [D] +-+ |-- * ->  [?7h[?25l[?7l Setup step 4 of 5   Choose composer shape   Pick the prompt and status line layout for your workflow.   Select a layout; live preview updates below. Press ⏎ to confirm.   Preview:    π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶────────────────────────────────────   ╰─ Ask anything, edit files, run tools    ❯ 1 Status Band (Default) Flush soft-capped status band above a curved prompt, no frame   2 Rounded Box Status line embedded in top border, compact 2-line prompt   3 Claude Code Full-width horizontal rules above and below, status line at bottom   4 Pi Framed horizontal rules with status line at bottom   5 Borderless Clean prompt glyph with status line at bottom, no box borders   6 Top Rule Dock Single top rule with status docked onto it and below   7 Compact Field Filled one-row field with accent end caps   8 Accent Rail Filled one-row field anchored by a single accent rail  [?7h[?25l[?7l Setup step 5 of 5   Pick a theme   Move through the list to preview; ⏎ saves the highlighted choice.   Theme changes preview live. Nothing is saved until you press ⏎.   ⎋ skips this step     Preview   ✔ success ⚠ warning ✘ error accent   Status line    ⬢ sonnet │ ~/project │ ⎇ main +2 ◫ 42% │ 💲 0.18    Editor   ╭──────────────────────────────────────────────────────────────────────────────────────╮   │> Ask anything, edit files, run tools  │   │⏎ send · ⇧⏎ newline · / commands │   ╰──────────────────────────────────────────────────────────────────────────────────────╯     Match terminal Titanium in dark terminals, Light in light terminals   ❯ Titanium Default dark theme   Light Default light theme   Colorblind colors Adjust red/green contrast   ANSI-safe ASCII glyphs with the dark terminal theme   Browse all… Show every built-in and custom theme  [?7h[?25l[?7l ✦ · ·   ✦ ·   ✦   · · · ·   ✦ ✦   · ✦ ✦ ✦   ✦ · ✦ ✦✦   ·   · ✦ ✦ ✦   · ✦ ✦   ✦ · ✦   ✦ ✦ ✦ ✦ ·   ✦ · ✦ · ·   ·   ✦ ·   ████████████   ██ ██   ██ ██   ▒▒ ██   ██     ✔ Setup saved   Handing off to the normal CLI…     ━───────────────────────────────────────────────────────────────────────────────────────────────────────────────   ✦ · · ✦   · ·     ✦ ✦ ✦   ✦ · ✦ ✦ ·  · ✦ ✦ ·   ✦ ✦   ✦ ✦   · ·   ✦ ✦ ✦   · · ·   · ·  · ✦ ·   · ✦   ✦ · ·  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━─────────────────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━──────────────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ██   ━━━━━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l · · · ✦   ✦ ·   · ✦ ·   · · ✦ ✦   ✦ · ·   ✦ · ·   · ✦ ·   ✦ ✦   · · ✦·   ✦ ✦ ·   ✦   ✦ · ✦   ·   · · ·     ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────────────────────────────────────────   · · ✦   ✦ ✦   · ✦   ✦ · · · ✦ ✦·   ✦   · ✦ ·   ✦ ·   · ✦   ✦ ✦   ✦ · ··   · ✦ · ✦   ✦ ✦ ·   · · ✦ ·   ✦ · ✦ ✦   ✦ ✦  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────────────────────  [?7h[?25l[?7l   ✦ ·   ✦   ✦ ✦ ✦   ✦ ·   ✦ ✦ · ✦   ·   · · ✦     ·   ·   ✦ ✦   ✦   · ·   ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────────────────────────────────────   · ·   · ·   ✦ ·· ✦   · ✦ ✦   ·   · ✦✦ ·   ✦   ✦ ✦ ✦ ✦   ✦ ✦ ✦·   ✦   ·   ✦   ✦ ✦   ✦ · ✦   ✦  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────────  [?7h[?25l[?7l · ·   ✦ ✦ ✦ · · ·     · ·   · · ✦ ·   ✦ ✦ · ✦   · ✦ ·   ✦ ✦ ·· ·   ✦ · ·   · ·   ✦ ✦ ✦ ·   ✦ ✦   ·   · ·   ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────────────────────   ✦ ✦ · ·   ✦   ✦✦ ✦   · ✦   ✦ ·   ✦ ·   ✦ · ✦ ✦ · ·   · ✦ ·   ✦ · ✦ ✦   · ✦ · ·   · ✦   · ✦ ✦   ✦ ·   ✦ · ✦ ·   ✦ ✦  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────────────────────────  [?7h[?25l[?7l ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────────────────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────────  [?7h[?25l[?7l ████████████   ██ ██   ▒▒ ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──────────────  [?7h[?25l[?7l ✦ ✦ ✦ · ✦   ·  · · · · ·   ✦·   · ·     ✦   ✦ ✦ ✦ ✦ ✦   · ✦   ✦ · · · ✦ ·   ·   · ✦         ████████████   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━───────────   ✦ ✦ ✦   · ✦   ✦ ·   · ✦ · · ✦ ✦     · · ✦ · ·   ·   · ✦   · · ✦ ✦   ✦ · ·   ·   · ✦   · ·   · ✦   · ✦ · ✦  [?7h[?25l[?7l ████████████   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━────────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━─────  [?7h[?25l[?7l ████████████   ██ ██   ██ ██   ▒▒ ██   ██   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━──  [?7h[?1006l[?1003l[?1000l[?1049l[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │  Warning: No models available. Use /login or set an API key environment variable. Then use /model to select a model.    π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶──────────────────────────────────────────── ╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l[?25h[?7h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/omp-18-setup.meta.json b/src/main/runtime/__fixtures__/omp-18-setup.meta.json new file mode 100644 index 00000000000..4f1c6f0f17c --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-setup.meta.json @@ -0,0 +1,15 @@ +{ + "capturedAt": "2026-10-01T05:49:50.289Z", + "platform": "darwin", + "command": [ + "/bin/zsh", + "-f", + "-c", + "cd && exec /dist/cli.js --no-session --no-extensions --no-skills --no-rules --no-lsp --no-prewalk" + ], + "cols": 120, + "rows": 40, + "note": "OMP 18.4.5 isolated HOME, no credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; command paths redacted; HOME/XDG/config isolated, no provider credentials; startup.checkUpdate=false, marketplace.autoUpdate=off; identifying cwd labels scrubbed with same-length replacements", + "exitCode": 129, + "sends": [] +} diff --git a/src/main/runtime/__fixtures__/omp-18-setup.txt b/src/main/runtime/__fixtures__/omp-18-setup.txt new file mode 100644 index 00000000000..9598ad7b77f --- /dev/null +++ b/src/main/runtime/__fixtures__/omp-18-setup.txt @@ -0,0 +1,84 @@ +[?25l[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ LSP Servers │ +│ ██ │ No LSP servers │ +│ │ │ +│  │ │ +│  │ │ +│ │ ───────────────────────────────────────────────────────────────────── │ +│ │ Recent sessions │ +│ │ No recent sessions │ +│ │ │ +│ │ │ +│ │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Press ⌃R to search your prompt history and reuse a past message + + + +╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ LSP Servers │ │ ██ │ No LSP servers │ [?25h[?7h[?2004h[?1l>[?u]11;?_25a1;s\_tsp;q;{"q":"hello","v":[1],"app":"omp"}\[?2031h[?2026$p[?2048$p[?2031$p[?2004$p[?1010$p[?1011$p[?2;1;0S[?25l[?7l╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ │ │ Tips │ │ Welcome back! │ # for prompt actions │ │ │ / for commands │ │ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ │ │ │ │  │ │ │  │ │ │ │ │ ╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯  Tip: Press ⌃R to search your prompt history and reuse a past message    ╰─   [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[>4;2m[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ │ ██ │ No recent sessions │ [?25h[?7h[?25l[?7l[?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ [?25h[?7h[?25l[?7l│ ████████████ │ ! to run bash │ │ ██ ██ │ $ to run python │ │ ▒▒ ██ │ Recent sessions │ │ ██ │ No recent sessions │ [?25h[?7h]0;π >]0;π > capture-cwd[0 q[?25l[?7l +╭─── omp v18.4.5 ──────────────────────────────────────────────────────────────────────────────────╮ +│ │ Tips │ +│ Welcome back! │ # for prompt actions │ +│ │ / for commands │ +│ ████████████ │ ! to run bash │ +│ ██ ██ │ $ to run python │ +│ ██ ██ │ ───────────────────────────────────────────────────────────────────── │ +│ ▒▒ ██ │ Recent sessions │ +│ ██ │ No recent sessions │ +│ │ │ +│ Unknown │ │ +│ Unknown │ │ +│ │ │ +╰──────────────────────────┴───────────────────────────────────────────────────────────────────────╯ + Tip: Press ⌃R to search your prompt history and reuse a past message + + + π > ⬢ no-model > 📁 …6-09-28/XXXXXXXXXXXXXXXXXXX/capture-cwd > ◫ 6.4K/? ⟲ ▶──────────────────────────────────────────── +╰─  ⇧⇥ to change thinking effort [?25h[?7h[?25l[?1049h[>4;2m[?1000h[?1003h[?1006h[?25l[?25l[?7l✦ · ✧ ✦  + ✧  + · ✧ ✧ ·  + ✦ ✦ · ✧  +· ✦ ✦ ✧  +  + ████████████████████████ ✦  + · ████████████████████████ ✦  + ✦ ✦ ████ ████ ✦  + ████ ████ · ✧  + · ✧ ████ · ████ ✧  + · ✧ ████ ████ ✧  + ✦ ▒▒▒▒ ████ ✦  + ▒▒▒▒ ████ ✧  + ✧ ████  + ████  + ░ ░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░▒▓▓███████▓████████▓▒░ ░░▒▒▒▒▒▓▓▒▓▒▒▒▒░░ ░  + ░ ░░▒▒▒▒▓▓▓▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░░  + ░░▒▒▒▓▓▓▒▒▒░░░ ░░▒▒▓█████████████▓▒▒░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░ ░ ░  + ░░▒▒▒▒▓▓▒▒▒░░ ░░░░▒▒▓████████████▓▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░  + ░░▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░  + ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░  + ░░░▒▒▒▒░░░░░ ░░░░░▒▒░░▒░░░░░░░░▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░░ ░  + ░ ░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░ ░░░░▒░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░  + ░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒▒░░░░░░░▒░░▒▒▒▒▒░░ ░░  + ░ ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░ ░░▒▒▒▒▒░▒░░░░▒░▒▒▒▒▒░░░ ░  + ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒░░▒▒▒░░░░░░  + ░░▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░ ░░ ░▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░░  + ░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░  + ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▓▓███▓▓▓▓▒▒░▒░░░▒░▒░░░░░  + ░░░░░░░░░░░░░▒▒▓▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░  + ░ ░░░░░░░░▒▒▒▒▓▓▓████▓▓▓▒▒░░░░░░░░░░░░░░  + ░░ ░ ░ ░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░  + ░░░░░░░ ░░░░░░░▒▒▒▒▒▒░░░  + ░ ░░ ░░░░░ ░░░ ░░░ ░░░░  + ░ ░░ ░ ░  + ░ ░  + ░░░ ░ ░ ░ ░░ ░ ░ ░░  + ░ ░░░░░░░ ░░░░░ ░░░░ ░ ░ ░░░░░░ ░  + ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ✧ ████   ████   ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ▒▒▓▓██████▓▓████████▓▒░░ ░░▒▒▒▒▒▓▒▓▒▒▒▒▒░░   ░ ░░▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓████████████████▓▒▒░ ░░░▒▒▒▒▒▓▒▓▒▒▒▒░░ ░   ░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓██████████████▓▓▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░▒▒▒▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░   ░▒▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▓▓▓████████▓▓▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░ ░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░   ░░░▒▒▒▒░░░░ ░░░░▒▒░▒▒░░░░░░▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░ ░   ░ ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░░░░▒░░░░░░░░▒░▒▒▒▒▒░░ ░ ░░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░▒░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░ ░░▒▒▓▓▒▒▒▒▒░▒░░░░░░ ░░░▒▒▒▒░▒░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▒▓▒▓▒▒▒░░░░░░░░▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░▒▒▒░░▒░░░░   ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓▓▓▓▒▓▓▓▓▓▓▓▓▒▒░▒░░░░░▒▒░░░░   ░░░░░░░░░░░░░░▒▒▓▓▓▓██▓███▓▓▓▒▒▒▒░░░░░░░░░░░   ░░░░░░░░▒▒▒▓▓▓██████▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░ ░░░ ░░░░░   ░ ░░░ ░ ░   ░   ░ ░ ░░   ░ ░░░ ░ ░░░░░ ░░ ░ ░ ░ ░ ░░   ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ▒▒▒▒ ████ ✧   ░ ░░▒▒▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▓███████▓▒▓████████▓▒░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░░▒▓█████████████████▓▒░ ░░░░▒▒▒▒▒▒▓▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░░ ░▒▒▓███████████████▓▒▒░░ ░░░░░▒▒▒▒▒▓▒▒▒▒░░ ░   ░░░▒▒▓▒▓▓▒▒▒░░ ░░░▒▒▓████████████▓▓▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓▓████████▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░   ░░░▒▒▒▒░░░ ░░░▒▒▒▒▒░░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░░ ░   ░░░░▒▒▒▒░░░░░░░░░░░░▒░░░░ ░ ░░░░░░░░░░░░░▒░▒▒▒▒▒░░ ░░   ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░ ░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░ ░░▒▒░▒░░░░░░░░▒▒▒▒▒░░░░ ░   ░▒▒▒▓▓▒▓▒▒▒▒▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░   ░░░▒▒▓▓▒▓▒▒▒░░░░░░░░░░░▒░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░ ░   ░░░░░░░░░░░░░░░░▒▒▓▓▓▓█▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░ ░   ░░░░░░░░░░▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░░░ ░░░░   ░ ░░░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░░░░░░░   ░░░ ░   ░ ░ ░   ░ ░ ░ ░ ░░ ░ ░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓██████▓▓▒▓████████▓▒░░ ░░░▒▒▒▒▓▒▓▒▓▒▒▒░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓████████▓████████▓▒▒░ ░░░▒▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▓▒▒░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒▒░░░ ░░▒▒▓▓█████████████▓▓▒░░░ ░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓███████████▓▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░ ░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓██████▓▓▒░░░ ░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░ ░   ░░░░░▒▒░░░░░ ░░░░░░░▒▒░░░░ ░░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░ ░░   ░░░░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░▒░░░░░░ ░░░▒░▒░░░░░░░░░▒▒▒▒░░░░░   ░░▒▒▒▓▓▒▒▒▒▒░▒░░░░░ ░░▒▒▒▒▒▒▒░░░░░░░▒░░░░░░░   ░░▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░▒▒░░░░░░░   ░░▒▒▒▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▓▓▓██▓███▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░ ░ ░░░░   ░ ░░░ ░░░░░▒▒▒▒▒▒▓▒▒▒▒░░ ░   ░░ ░░░░░ ░░░░ ░░░░░▒░░▒░░   ░░░ ░ ░   ░     ░ ░ ░░ ░ ░ ░ ░░   ░ ░░░░░░░░░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▒▒▒████████▓▒▒░ ░░▒▒▒▒▒▒▒▒▓▒▒▒░░░   ░░▒▒▒▒▒▒▓▒▒▒▒░░░ ░▒▓▓███████▓▓████████▓▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░░▒▒▓████████████████▓▒▒░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▓▓▓▒░░░░ ░░▒▒▓██████████████▓▓▒░░░ ░░░░░▒░▒▒▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▓▒▒▒▒░ ░░░░▒▒▓▓████████████▓▒▒▒░░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓███████▓▒▒░░░░ ░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░▒░░▒░░░░ ░░░░░░▒▒░░░░░░░░░▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░░   ░░░░▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░▒░░░░░░░░░░░░▒▒▒░▒░░░   ░░▒▒▒▓▒▒▒▒▒▒░▒░░░░░ ░░░▒▒▒░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░▒░▒░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░▒░░░░░░   ░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓██▓▓▓▓▒▒░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░ ░░░░   ░░ ░ ░░ ░░░░░░░░░░▒▒▒▒░░░   ░░░ ░ ░ ░ ░░   ░ ░ ░ ░ ░░   ░ ░░░░ ░░░░░░ ░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▓█████▓▓▒▒▒▒▓▓███████▓▒░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓████████▓▒▓████████▓▒░░ ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▓▒▒▒▒░░░ ░▒▒▒▓████████████████▓▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▒▓███████████████▓▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▒▓▓████████████▓▒▒▒░░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓▓████████▓▒▒▒░░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░░▒░░░ ░░░░░▒▒░░░░░░░▒▒▒░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░ ░   ░░░░▒░▒░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░▒░▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒░░░░░░░░░░░░░░░░░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░░▒▒▒▒▒░░░░░░░░▒░░░░░░░░   ░░▒▒▒▓▓▒▓▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░▒▒▒░▒░▒▒▒▓▒▓▓▓▒▒▒▒░░░░░░▒░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓██▓▓▓▓▒▒▒▒░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░░   ░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░ ░░░░░   ░░ ░ ░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░░ ░ ░░ ░░░░░     ░ ░   ░ ░░░░ ░ ░ ░░ press ⏎ to skip ░░░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✦   · ████████████████████████ ✦   ✦ ✦ ████ ████ ✦   ████ ████ · ✧   · ✧ ████ · ████ ✧   · ✧ ████ ████ ✧   ✦ ▒▒▒▒ ████ ✦   ▒▒▒▒ ████ ✧   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓████▓▓▒▒░░▒▓▓███████▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓███████▓▒▒▓▓███████▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░▒▒▒▒▒▒▒▒░▒░░░ ░▒▒▓▓████████████████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▓▒▒▒▒░░░ ░░▒▓▓███████████████▓▒▒▒░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒░░   ░░▒▒▓▒▓▒▒▒░░ ░░░░▒▓▓█████████████▓▓▒▒░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▒▒▒░░ ░░░░▒▒▒▒▓▓██████████▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░   ░░░▒▒▒░▒░░░ ░░░░░▒▒░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░░▒▒▒░░░░░   ░░░░░▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░▒░▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░▒░░░░░░░░░░░░░░░▒░░   ░░▒▒▒▓▓▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░░░░▒▒░░░   ░░░▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▓▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░   ░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░░░░░   ░░ ░ ░░░░░░░▒▒▒▒▒▒▒▒▒░░░   ░░░ ░ ░░░░░░░░░░   ░   ░ ░ ░ ░░ press ⏎ to skip ░░ ░ ░  [?7h[?25l[?7l ·   ✧ ✦ ✧   · ✧   ✧   ✦   ✧ · · ✧   ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ████   ░░░░░▒░░▒▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░▒▒▒▓███████▓▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░   ░░░░░▒▒░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▓▓██▓▓▒▒▒▒░░▒▒▓▓███████▓▒▒░ ░░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░░▒░░░░░░ ░░░▒▓▓▓█████▓▓▒▒▒▓▓██████▓▓▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████▓███████▓▓▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒░░   ░▒▒▒▓▓▒▒▒▒░░░ ░░░▒▒▓▓██████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▓▒▒▒░ ░░░░▒▒▓▓█████████████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓▓█████████▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒░░░░   ░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░░░ ░░░░▒▒▒░░░░   ░░░▒▒▒░░░░ ░░░░░░░▒░░░░░░▒▒▒▒▒░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░   ░░░░▒░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░▒░▒░░░   ░░░░▒░▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░▒▒░░░░   ░▒▒▒▒▒▒▒▒░▒░░░░░░░░ ░░░░▒░░░░░ ░░░░░░▒▒░░░   ░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▒░░░░░░░░░░░▒▒░░░░░░   ░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░▒░░░░░░   ░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▒▒▒▒▒░░░░░░░░▒░░░░░░░   ░░░▒▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░ ░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒░░░ ░░ ░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒░░░ ░ ░   ░ ░ ░ ░░░░▒▒▒▒▒▒▒▒░░ ░ ░   ░ ░ ░░░░░░░░     press ⏎ to skip  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   ░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░▒▓▓▓█▓▓▓▒▒░░ ░░░▒▓███████▓▓▒░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░▒▒▒▒▒░░░░░░░░░░▒▒▒▓▓▓▓▓▒▒░▒░░░░▒▒▓▓██████▓▒▒░ ░░░░▒░▒▒▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░▒▒▓▓▓██▓▓▓▒▒▒▒▒▓▓▓█████▓▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓███████▓▓▓▓▓████▓▓▓▒▒░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░   ░▒▒▒▓▓▒▒▒░░░░ ░░░▒▒▓▓███████████████▓▓▒▒░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░ ░░░░▒▒▓▓█████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓██████████▓▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░▒░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒▓▓▓▓██▓█▓▓▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░▒░░░░   ░░░░▒▒▒░░░░ ░░░░░░▒░▒░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░░ ░░░░░░░░░▒░░   ░░░░▒░░░░░ ░░░░░░░ ░░░ ░ ░░░░░░░░ ░ ░░░░░░░░▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░ ░░░░▒▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░▒░░░░░ ░░░░░░░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░▒░░░░░░░░░░░░░░▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░░░░░▒░▒░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░   ░░░░▒▒░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒░░░ ░ ░ ░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░   ░░░░░░░░░░ ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ░░░░░░░░░▒▒▒░░░░░░░░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░▒▒▓███████▓▒▒░ ░░▒░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒░░░▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓██████▓▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░░░   ░░░░░▒▒░▒▒░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓█████▓▒▒░░ ░░░░░▒▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░▒▒▒▓▓█████▓▓▓▓▓▓▓████▓▓▓▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓████████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░   ░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▓▓▓█████▓▓▓▒▒░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒░░░ ░░░░░░▒░▒░▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒░░░   ░░░░▒░▒░░░░ ░░░░░░░░ ░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░▒▒░░░   ░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒▒▒▒░░░░░░░░░░░░ ░░░░▒░░░░░ ░░░░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░▒▒▒▒▒▒▒░░░░ ░░░░░▒░▒░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒▒▒▒░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░▒▒▒▒▓▒▓▓▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▒▓▒▒▒▒░░ ░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒░░ ░ ░░   ░░░░░░░░░░░░   ░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   · ████✧ ████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒░░▒▒░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓█████▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░▒▒░░▒░░░░░░░░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▓▓█████▓▒▒▒░░ ░░░░░▒░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░▒░░░░░ ░ ░░░ ░░░▒▒▒▓▓████▓▓▓▓▒▓▓▓████▓▓▓▓▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░░▒▓▓██████████▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▓▓███████████▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓██████▓▓▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░░▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░▒▒░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░▒░░░░   ░░░▒░▒░░▒░░░░░░░░░░░░ ░░░░░░░░ ░ ░░░░▒░░░░░░   ░░░▒▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒▒▒▒▒░░░░ ░░░░░░░▒░░░░   ░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░▒░░░░░░░░▒▒▒░▒░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░░░░░▒▒▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░   ░ ░░░░▒▒▒▒▓▒▓▒▒▒▒▒░░░ ░ ░░░   ░░░░░░▒▒░░░░░   ░ ░░  [?7h[?25l[?7l ✦ ████████████████████████ ·   ✦ ████████████████████████   ✦ ✦ · ████ ████   ✧ ████ ████   ████· ████   ✦ · ✧ ▒▒▒▒ ████ ✦ ✦ ·   ✧ ▒▒▒▒ ████ ✦ ✦   ✦ · ████   ████   ░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓█▓▓█▓▓▒░ ░ ░░░▒▓▓███████▓▒░ ░░░▒▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░▒░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓██████▓▓▒░░ ░░░▒░▒▒▒▒▒▒▒░░░   ░░░░░░▒░░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░░░░▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▓▓████▓▒▒▒▒▒▒▓▓▓██▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓████████▓▓▓█████▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░▒▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▒▒▒░░░░░░░░░░░░░░ ░░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓████████▓█▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░ ░ ░░░░░░▒░░░   ░░▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒▒░▒░░ ░░░░░░░░░░ ░░░░░▒░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░▒░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░▒░░░▒░░░░ ░░ ░░░░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░░░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▒▒▒▒░░░ ░ ░░░   ░░░▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░░░  [?7h[?25l[?7l   ✧ ✧ · ✦   ✧ ✧ ✦✧   · ✧   ✧   · ✦   ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▓▓██▓█▓▒░░ ░░░ ░░▒▓███████▓▒▒░ ░░░░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒░░ ░░░▒▒▓▓█████▓▓▒░░ ░░░░░▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒░░▒░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓█▓▓▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▓▒▒░░░ ░░░░░░░░░░░▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒░░░░░ ░░▒▒▓▓███████▓▓▓▓▓▓███▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░▒▒░▒▒░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓██████████████▓▓▓▓▒▒░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓█████████████▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓▓██████████▓▓▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░░░░ ░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒░▒▒░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░▒░░░░ ░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░▒░▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░▒▒▒▓▓▓▒▓▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▓▓▒▒▒░░░ ░ ░░░░░ ░   ░ ░░░░▒▒▒▒▓▒▓▓▓▒▒▒▒▒░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░▒░░ ░░   ░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▒▓▓▓███▓▓▒▒░ ░░░ ░░▒▒▓██████▓▓▒░░ ░░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░ ░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░▒▒▓▓█████▓▓▒▒░ ░░░░▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒░░░░░░░ ░░░░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░▒░░▒░░░░   ░░▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓███████▓▓▓██▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒░░   ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓▓██████████▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒░░░ ░░░░▒░░░▒▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░░░░░▒░▒░░░ ░░░░░░░░░░ ░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░ ░░░░░▒▒▓▒▓▓▓▓▒▒▓▓▓▓▒▒▒░░░░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░░ ░ ░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░   ░░░░░▒░▒▒░░░░░   ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ░░░░░░░ ░░░ ░░░░░░░░░▒▒▓▓▓████▓▓▒▒ ░░░ ░░▒▓▓██████▓▓▒░ ░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░▒▒▓██████▓▒▒░░ ░░░░▒░▒▒▒▒▒▒░░░░   ░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓██▓▓▒▒▒░░░ ░ ░░░░░░▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░░ ░░░░░░░░░░░░▒▒▒▒░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓██████████████▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░ ░░░▒▒▒▓▓███████████▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓▓▓██████▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░ ░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░   ░░░░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▓▒▓▓▓▒▒▒▓▒▓▓▒▒▒░░░░░░ ░░░░░░░   ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▒▒▒▒░░ ░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░   ░░░░░▒▒▒▒░░░░░░   ░░ ░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░ ░░░ ░░░▒▒▓▓█████▓▓▒░ ▒░░ ░░▒▓▓██████▓▒░░ ░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░▒▓▓█████▓▒▒░░ ░░░░░░▒▒▒▒▒▒░░░░   ░░░░░░ ░░ ░░░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▓▓█▓▓▓▓▓▒░░░ ░░░░░░░░▒▒▒▒▒░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░▒░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░   ░░░▒▒▒▒░░░░░ ░ ░░░░░░▒▒▓▓▓█████▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░▒▒░░░   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▓██████████▓█▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓██████▓▓▓▓▓▓▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▓▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░ ░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░   ░░░░░▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░░░▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒░▒▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒▒▒▓▒▒▒▒▓▒▒▓▒▒▒░▒░░░░ ░░░░░░░░   ░ ░ ░ ░ ░░░░░▒▒▒▓▓▓▒▓▓▓▓▒▓▒▒▒░░░░ ░ ░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░   ░░░▒▒▒▒▒▒░▒▒░░░░ ░   ░░░░░░░ ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░ ░░░ ░░░▒▒▓██████▓▓▒░ ░▒░░ ░░▒▓▓██████▓▒▒░ ░░░▒▒▒▒▒▒▒░▒░░   ░░░░░░░ ░░░░░░░░▒▒▒▓▓███▓▓▒▒░ ░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░░   ░░░░░ ░░░░░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░▒▒▒░▒░░░░   ░░░░░░░ ░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒░▒░▒▒▒▒▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░░   ░░░▒▒░░░░░░░ ░ ░ ░░░░░░░▒▒▒▓▓▓▓██▓█▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░▒▒▒▓▓████████▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░░ ░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░░░ ░ ░ ░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░▒▒░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒░░░░░ ░ ░░░░▒░░░░░░   ░░▒░▒▒▒▒░░░░░░░░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░░ ░░░░░░░░░░   ░ ░ ░ ░ ░ ░░░░░▒▒▒▒▓▓▒▓▒▓▓▒▓▒▒▒▒░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░   ░░░░▒▒▒▒▒▒░▒▒░░░░░ ░   ░░░░░░░░░░░░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ████ ·████   ████ ████   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ░░░░░░░░░░ ░░░▒▒▓██████▓▒░░ ░▒▒░ ░░▒▒▓▓█████▓▒▒░░ ░░░░▒▒▒▒▒▒░▒░░   ░░░░░░░ ░ ░░░░░▒▒▒▓▓▓███▓▓▒▒░ ░░░░░░░░░▒▓▓████▓▓▓▒░░ ░░░░░░▒▒▒▒░▒░░░   ░░░░░░░ ░ ░░░▒░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░ ░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░░░░ ░ ░░░░░░░░▒░░░▒░░░   ░░░░░░░░░ ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░▒░░░░░   ░░░░▒░░░░░░░ ░ ░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▓▒▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▓▓██████████▓█▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒░░ ░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓████▓▓▓▓▒▓▒▒▒░░░░░░░░░▒░░░░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░▒░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░ ░░ ░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░░▒░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒░░░░░ ░ ░░░░░▒░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░   ░░░░░▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▓▒▒▒▒░░░ ░ ░░░░░░░░   ░░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░  [?7h[?25l[?7l ✧ · ████████████████████████·✧   ████████████████████████ ✦ ·   ✦ · ████ ████ ✦   ✧ ████ ████ ✧ ✧ ✦ ✦   ✦ ▒▒▒▒ ████   · ▒▒▒▒ ✦ ████ ✧   ✧ ████   ████   ░░░░░░░░░░ ░░▒▒▓██████▓▓▒░ ░▒▒▒░ ░░▒▒▒▓██████▓▒░░ ░░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░░▒▒▒▓█████▓▒▒░░ ░░▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░▒▒░▒░▒░░░   ░░░░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░ ░░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░ ░░░░▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒░░░░░░░ ░ ░░░░░░░░░░░░▒░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▒▒▒▒░░░░░▒░▒░▒▒▒▒▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░   ░░░░░▒░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░░░░░░   ░░▒▒▒░░░░░░ ░░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░▒▒▓▓█████████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░░ ░ ░░░░░░   ░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▓▒▓▒▒▒▒▒░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░░░░▒▒░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░   ░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░▒░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░ ░░ ░░░░▒░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░░░   ░░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░░   ░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░░▒░░░░░  [?7h[?25l[?7l · ✧     ✦ ✧ ·     · · ·   ✧   · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░░░░░░░ ░░▒▓▓██████▓▒▒░ ░▒▒▒▒░░ ░ ░▒▒▓▓█████▓▒▒░░ ░░░▒░▒▒▒░▒░░░   ░░░░░░░░ ░░░░▒▒▓▓█████▓▒▒░ ░▒▒░░░░░░▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░░░▒▒░▒░▒░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▓▒░ ░░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░░   ░░░▒░▒░░░░░ ░░ ░░░░▒▒▒▓▓████▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░░░░░ ░░░░░░░░   ░░▒▒▒▒▒▒░░░ ░░░▒▒▒▓▓████████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░▒░░░░ ░░░░░░░░░░░░░▒▒░▒▒░▒▒░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░ ░░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░ ░ ░░░░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░▒▒░░░░░ ░ ░░░░▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░   ░░▒▒▒▒▒░░░░ ░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░░░░   ░ ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░ ░░▒▒▒▒▒░░░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒░ ░▒▓▒▒░░ ░ ░▒▒▒▓█████▓▓▒▒░ ░░░░░▒▒▒░▒░░░   ░░░░░░░░░ ░░░▒▒▒▓▓█████▓▒░░ ░░▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒░░ ░░░░▒▒░░░▒▒░░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓█▓▓▒▒░ ░▒░░░░░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▓▒▓▓▓▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░ ░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░░░░░░░░░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▓▓▒▒▒▒▒░░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░▒░░░░ ░ ░░░░░░░░▒▒▒▓▓▓▓██▓▓▓▓▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░   ░▒▒▒▒▒▒░░░░ ░░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░░▒░░░ ░░░░░░   ░░▒▒▒▒▒░░░ ░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░ ░   ░░░░▒▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░ ░░░░░▒░░░░░ ░░░░▒▒░░░░   ░▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░ ░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░▒▒░░▒░░▒░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░▒▒▒▒▒░░░░░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ░░░░░░░░░░ ░▒▒▓▓██████▓▒▒ ░▒▓▒▒░░ ░ ░░▒▒▓█████▓▓▒▒░ ░░░░░░▒▒░▒░░░░   ░░░░░░░░ ░░░▒▒▓▓██████▓▒░░ ░░▒▒▒▒░░░░░▒▒▒▓▓█▓▓▓▓▒▒░░ ░░░░░▒░░░▒▒░░░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓██▓▓▒░ ░▒░░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░ ░░░░░░░░▒░▒░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░▒░░░░░▒░░░░ ░░░░░░░░   ░▒░▒░░▒░░ ░░░░░▒▒▒▓▓███████▓▓▓▒▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░▒░░░░░░ ░░░░░░░   ░░░▒▒▒▒░░░ ░░░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░░ ░ ░░░░░░░░ ░   ░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░░░░░░░░░   ░░░░░▒▒░░░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░   ░░▒▒▒▒▒▒░▒░░░░░░░░ ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░   ░░ ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░   ░░░░▒▒▒▒▒▒▒░░▒░░░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ░░░░▒░░░░░░ ░▒▒▓███████▓▒░ ░▒▒▓▓▒▒░░░ ░░░▒▒▓█████▓▒▒░░ ░░░░░░▒░▒░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒▒░ ░░▒▒▒▒▒░░░░░░▒▒▒▓█▓▓█▓▒▒░░ ░░░░░░░░░▒▒░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓██▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░ ░░░░░░░▒░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░▒▒▓▓▓▓▒▒▒▒░░░░░ ░ ░░░ ░░░░░░░░░░░░   ░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒░░▒░▒▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░ ░░░░░▒▒▒▓▓█████▓▓▓▓▓▒▓▒▓▓▓▓▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒░░░░░░ ░ ░░░░░░   ░░░░▒▒▒▒▒░░ ░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░░▒▒▒░░░░ ░░░░▒▒▒▒▓▓▓███▓▓▓▓▓▓▓▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░░░   ░░░░▒▒▒░░ ░░░░░░▒░▒▒▒▒▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░░░░░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░ ░ ░░░░░░░░▒░░░░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒░░░░░░ ░ ░░░░░░▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░ ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░   ░░░░▒▒▒▒▒▒▒░░▒▒░░░░ ░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ░░░░▒░░░░░░ ░▒▒▓███████▒▒░ ░▒▒▓▓▓▒░░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓█████▓▒░░ ░▒▒▒▓▒▒░░░░░░▒▒▒▓▓▓▓█▓▓▒▒░ ░ ░░░░░░░░▒░░░   ░░░░░░░░░ ░░▒▒▒▓▓▓████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░▒▒▒▓▓▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░░░░░░   ░░░░░ ░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░ ░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░▒░░░░░▒░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░▒▒▒▓▓█▓██▓▓▓▓▒▓▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░   ░░░░▒▒▒▒░░░ ░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░▒░░▒▒░▒░░░ ░░░░░   ░░▒░▒▒▒░░░░ ░░░▒▒▒▓▓███████▓▓▓▓▓▓▓▓▒▒▒▒░░░░░░▒░▒░░░░ ░░░░░░   ░░░▒░▒▒▒░░ ░░░░▒▒▒▒▓▓▓████▓▓▓▓▓▓▓▒▒▒▒░░░░░░░▒░░░░░ ░░░░░   ░░░░░▒▒░░░ ░░░░░░▒▒▒▒▒▒▓▓▒▓▓▒▒▒▒▒▒░░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░ ░░░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░░▒░▒░▒░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░░ ░░░░░░░░░░ ░░░░░░░░▒░▒▒░▒░░░░ ░░░░░░░░░░   ░░░░▒▒░░░░░░ ░ ░░░░░░▒▒░░▒░▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░ ░   ░░░▒▒▒▒▒▒▒▒░░▒▒░░░░ ░░ ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ░░░░▒░░░░░░ ░░▒▒███████▓▒░░ ░▒▒▓▓▓▓▒▒░░ ░░░░▒▓▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▒▓██████▓▒░ ░▒▒▒▓▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░ ░░▒▒▒▓▓█████▓▒▒░ ░░▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒░░░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░░░░░░░░░░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░░░░░░░▒▒▒▒▒▓▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░ ░░░░▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░▒▒▒▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▒▒▒░░░░░░░▒▒░░░░ ░░░░░░   ░░░░▒░▒▒░░░ ░░░░░░░▒▒▒▓████████▓▓▓▓▓▓▓▓▓▓▒▓▒▒▒▒░░▒▒░▒▒░▒░░░░ ░░░░   ░░▒░▒▒▒░░░ ░░░▒▒▓▓▓███████▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░░▒░▒░░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▒▓▓██████▓▓▓▓▓▓▓▒▒▒▒▒░▒░░░░▒░░░░░ ░░░░░   ░░░▒░▒▒░░░ ░░░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ ░ ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░   ░░░▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░░░░ ░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░▒░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░ ░░░░░░░░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░ ░░░░░░░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░░░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ✦ ✧ ✧ ████ · ✦   ████   ░░░░▒░░░░░░ ░▒▒▒██████▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░ ░░░░▒▒▓▓████▓▓▒░░ ░░░░░▒░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▓▒░ ▒▒▒▓▓▓▒▒▒░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ░▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▓▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒░░░░░░ ░ ░░░░░   ░░░░░░░ ░ ░░░░░░░░░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░░░░▒▒░░░░░░ ░░░░░   ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒▓███████▓▓▓▓▒▒▒▓▓▓▓▒▓▒▒▒▒░▒▒▒░▒▒░▒░░░░ ░░░░   ░░▒░░▒▒░░░ ░░░▒▒▓▓████████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░▒░░▒░▒░▒░░░░ ░░░░░░   ░░░▒▒▒▒▒░░ ░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒░▒░░░░▒░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░ ░   ░░░░░░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░ ░ ░░ ░░░░░░░░░░ ░ ░ ░░░ ░░ ░░░░░░░   ░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░ ░░ ░░░░ ░░░░░░░░░   ░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░░░░▒░░░░ ░ ░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒▒▒░▒░▒▒▒░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░   press ⏎ to skip ░  [?7h[?25l[?7l · ████████████████████████   ✦ ✦ ████████████████████████   ████ ████ ·  ████ ████ ✦   ████ ████ ·   ████ ████ ·   ✧ ▒▒▒▒ ████ ✦  ▒▒▒▒ ████   ████   ░░░░░░░░░░░ ░▒▒▓██████▓▓▒░░ ░▒▒▓▓▓▓▓▒▒░░░░░ ░░▒▓▓████▓▓▒▒░░ ░░░░░░▒░░░░░░   ░░░░░░░░░ ░░░▒▓▓█████▓▒▒░ ░▒▒▓▓▓▓▓▒▒░░░░░░░▒▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓█████▓▓▒░ ▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░ ░░░░░░░░░░░   ░ ░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░░ ░░░▒▒▒▒▒▓▒▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░▒░▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░ ░░░░░   ░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░ ░░░░░░░▒▒▒▓▓▓████▓▓▓▒▒▒▒▒▒▒▓▒▒▓▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░   ░░▒░░░░░░░ ░░░▒▓▓▓███████▓▓▓▒▓▒▓▓▓▓▓▒▒▒▒░▒░░▒░▒▒▒░░░░ ░░ ░░░   ░░░▒░▒░░░ ░░░░░▒▒▓▓▓██████▓▓▓▓▓▓▓▓▓▒▒▒▒▒░░░░▒░░░░░░░ ░░░░░   ░░░▒▒▒▒░░░ ░░░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░ ░░░░ ░   ░░░▒▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░ ░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░▒░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░░░░░░░░░▒░░░▒░▒▒░░░░░░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒░▒░░▒▒░▒▒▒▒▒▒▒░░░░░ ░░ ░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░   ░ press ⏎ to skip ░ ░  [?7h[?25l[?7l ✦ · ✧ ·   ✦ ·   · ✧   ·     ✧ ✦ ✧   ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ░░░░░░░░░░░░ ░▒▒▓█████▓▓▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓█████▓▒░░ ░░▒▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░░░░░░ ░▒▒▒▓▓█████▓▒▒░ ░▒▒▓▒▓▒▒░░░░░░░░▒▒▓▓▓▓▓▒▒░░░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓▓██▓▓▓▒▒░ ░░▒▒▒░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░░░ ░░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░ ░░░░░   ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░░░░░░▒▒▒▓▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░░░░   ░░▒░░░░░░░ ░ ░░░▒▓▓▓███████▓▓▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░ ░   ░░░▒░▒░░░ ░░░░░▒▒▓▓███████▓▓▓▓▓▓▓▓▓▓▒▒▒▒░░░░▒░░▒▒░░░ ░░░░░ ░   ░░░▒▒▒░░░ ░░░░░▒▒▒▓▓▓███▓▓▓▒▒▓▒▒▒▒▒▒░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░ ░░░░░▒░▒░▒░░░░░░░░░ ░ ░░░░░░ ░░░░░░ ░   ░░░░░░░ ░░░░░ ░░ ░░ ░ ░░░░░ ░░░░░░   ░ ░░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░░ ░░░░░░░░░   ░░░▒▒▒░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░   ░░░░▒▒▒▒░░░░░░░░░░░ ░ ░ ░░░░░░░░░░░ ░░░░░░░░░   ░░░░▒▒▒░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░▒░░▒▒▒▒░░░░░░░ ░░░░░░░░░░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░ ░░░░░░   ░ press ⏎ to skip ░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▒▒░ ░ ░░░▒▒▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▒▓▓▓███▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▓▓▓▓▓▓▓▓▒▒░░░░░░░▒▒▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░░░░   ░░░░░░░░ ░ ░▒▒▒▓▓███▓▓▓▒░░ ░░▒▒▓▓▓▓▒▒░░░░░░░▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░░ ░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░░░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒░▒░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░▒▒▒▒▒▒▒▒░▒░░░▒░░░░░░░░ ░░░   ░░ ░░ ░░░░▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒░░░░░ ░░░   ░░░░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░░░░   ░░░░░░░ ░ ░░ ░░░░▒▒▓▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░ ░░ ░   ░░░▒░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▓▒▓▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░ ░░░░░   ░░░░▒░░░░ ░░░░▒▒▓▓▓█████▓▓▓▒▒▒▓▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░░   ░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░   ░░░░▒░░░ ░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░ ░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░ ░░░░░░░░░░░░░░ ░░░░░ ░░░░░░   ░ ░░ ░░░░░░ ░ ░ ░░░░░░░░   ░░░░░ ░ ░░░░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░ ░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░ ░░ ░ ░ ░░░░░ ░░░░░░░░░░   ░░░░░▒▒▒░░░░░░░░░░░░ ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░▒▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒░▒▒▒▒▒░▒▒▒▒▒░░░░░░ ░ ░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░░░ ░░░░░░   ░░░ press ⏎ to skip ░░░ ░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ░░░░░░░░░░░░ ░░▒▓▓█▓█▓▓▒▒▒░ ░ ░░▒▒▓▓▓▓▓▓▓█▓▓▒▒░░░ ░░▒▓▓▓███▓▓▒▒░ ░░░░░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓███▓▓▒▒░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒░░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓████▓▓▒▒░░ ░░▒▒▒▓▓▓▓▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒░░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▒▒▒▒░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░░░░░░▒▒▒▒▒▒▒░▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░░ ░ ░░░▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒▒░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒░░░░ ░░░   ░░░░░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░ ░░░░   ░ ░░░░░░░ ░░ ░░ ░░░░▒▒▓▓▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░░ ░   ░░░░░░░░░ ░░░░▒▒▒▓▓██████▓▓▓▓▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░░░░   ░░░░░▒░░░░ ░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░▒░░░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒░▒░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░ ░░░░░ ░ ░░░░░░   ░░░░░░ ░ ░░░░ ░ ░ ░ ░ ░░░░░░ ░   ░░░░░░ ░ ░░░░░░ ░░░░░░░ ░   ░░░░░░░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░░░░░ ░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░░░ ░░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒░░░░░░░ ░░░░░░░░░░░   ░░░░░░░░ ░░░░░▒░░▒░▒░░▒░▒▒░▒▒▒▒▒░░░░░░ ░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░   ░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ░░░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓█▓▓▒▒▒▒░ ░░▒▒▓▓▓██▓▓▓▒░░ ░░░░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒▒▓▓▓▓█▓█▓▓▒▒░░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░ ░░ ░░░░░░░░░ ░   ░░░░░░░ ░░▒▒▒▓███▓▓▓▒▒░ ░▒▒▓▓▓▓▓▓▓▒▒░░░░░░░░▒▒▒▒▓▒▒▒▒░░░ ░ ░ ░░░░░░   ░░░░░░░ ░ ░▒▒▒▒▓▓███▓▓▒▒░ ░▒▒▒▓▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░ ░ ░ ░ ░░░░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░ ░░░░▒▒▒▓▒▒▓▓▓▓▓▒▒░░ ░▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░░░░░░░▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░ ░   ░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░ ░   ░░ ░░░░░░░░░░░░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░   ░ ░░░░░ ░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▓▓██████▓▓▓▒▒▒▒▒▒▓▓▒▒▒▒▒▒░░▒░▒▒▒▒░░░░ ░░ ░░   ░░░░░▒░░░░ ░░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒░░░░ ░░░░   ░░░░░▒░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░░░░   ░░░░░▒░░░ ░░░░░░░▒▒▒▒▒▓▒▒▒▒░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░ ░░░░░░ ░   ░░░░░░ ░░░░ ░░░░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░░░ ░░░░░░░░░   ░ ░░░░░░░░ ░░░░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░░░ ░ ░░░░░ ░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░ ░ ░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░▒░░▒░░▒▒▒▒░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░▒░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░ ░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓███▓▓▓█▓▓▓▓▒▒░░ ░░░▒▓▓▓▓██▓▓▒░░ ░░░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▓██▓▓▒▒▒░░░ ░░░▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░ ░   ░░░░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▒▒▓▒▒▒▒▒░░ ░ ░░░░░░ ░   ░░░░░░░ ░ ░▒▒▒▒▓▓██▓▓▒▒░░ ░░░▒▒▓▓▓▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░░   ░░░░░░ ░░░░▒▒▒▓▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░░   ░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒░░░░░░░░░░▒░▒▒▒▒▒▒░░░▒░░░▒░░░░ ░ ░   ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░   ░░ ░░░░▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░ ░ ░░   ░ ░░░░ ░░░░░░░░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒░░░░ ░ ░░   ░░░░░░░░░ ░░░░░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░ ░ ░░   ░░░░░▒░░ ░░░░░▒▒▒▓▓▓█▓▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░   ░░░░░░░░░░ ░░░░░░░▒▒▓▓▒▓▓▒▒▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░░   ░░░░░░░░░ ░░░░░░░░░▒▒▒░▒░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░   ░ ░░░░░ ░░░░ ░░░ ░░░░░░ ░ ░ ░░░░░░░   ░ ░░░░ ░░░░ ░░░░░░░░░░   ░░ ░░░░░░ ░ ░░░░░ ░ ░ ░░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░   ░░░░░░▒▒░░░░░░░░░ ░ ░░░ ░ ░░ ░░░░░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░▒▒░░░░░░ ░░░░░░░░   ░ ░░░ ░ ░░░░░░▒▒▒▒░▒▒░▒░▒▒░░▒░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░ ░ ░░░░ ░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ✦ ✧ ✧ ✦   ████   ░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓████▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▓▓██▓▓▓▓██▓▓▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓█▓▓▒▒▒░ ░░▒▒▒▓▓▓▓█▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▒▒▒░░░ ░ ░ ░░░░ ░   ░░░░░░░ ░ ░▒▒▒▒▓▓██▓▓▒▒░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▒▒░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░░░░▒▒▒▓▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒░░░░░░ ░░░░▒░░▒▒▒░░░▒░░░▒░░░░░ ░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░▒▒░▒▒▒░░░░   ░ ░░░░░▒▒░▒▒░░░░▒▒▒▒▒▒▒▒░▒░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░ ░░   ░ ░░░ ░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░░░ ░ ░░░░░▒▒▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▓▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒░░░▒▒░░░░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓▓███▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░░░   ░░░░░░░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▒▒░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░░░░▒▒▒▒▒▒░░░░▒░▒░░░░░░░░░░░░░░ ░░░░░░░   ░ ░░░░ ░░░░░ ░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░░ ░ ░░░░░░░░░   ░░ ░ ░░░░ ░░░░░ ░ ░ ░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░▒░░░░░░░░░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░░░░ ░░░░░░░░░░ ░░░░░░░░░░   ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░ ░░░ ░ ░░░░░░░▒▒░░░▒░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░░░░░░  [?7h[?25l[?7l ✦ · ████████████████████████   ✧ ████████████████████████   ████ ████ ✧   ✦ ████ ████ ✧   ✧ ████ ████   ✧ ✧ · ████ ████ ✦   · ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ████   ░░░░░░░░░░ ░░▒▒▒▓▒▓▒▒▒░░░░ ░░▒▒▓▓█████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓██▓▓▓▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓██▓▓▓▓██▓▓▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░▒▒▒▓▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓██▓▓▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░░░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░ ░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░░▒░▒░░░▒░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░ ░░░░▒▒░▒▒▒░░░▒▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░░░░░░░▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░ ░░   ░ ░░░ ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░░░░ ░ ░░░ ░░░░░▒▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░ ░░   ░░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░ ░░░░░ ░   ░░░░░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░ ░░   ░░░░░░░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░▒▒░▒▒░░░░░░░░░░░░░ ░░░░░░░   ░░░░░░ ░░░░░ ░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░░ ░ ░░░░░░░░░   ░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░ ░ ░ ░ ░░░░░░░░   ░░░░░▒░░░░░░░ ░░░░ ░░ ░ ░░░░░░░░░ ░░░░░░░░░   ░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░▒░▒░░░░░░░░░░░░ ░░░░░░░░░   ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░  [?7h[?25l[?7l · ✦ · ✦ ✦ ·   ✦   ✧   ✧ ✧   ✦ ✦ ·   ✧   ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ░░░░░░░░░░ ░░░▒▒▓▓▒▓▒▒░░░░░ ░▒▒▓▓██████▓▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▓██▓▓▓▒▒░ ░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓██▓▓▓▓███▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒░░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓█▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░▒▒░▒▒▒▒░░░░░░░░░░░░ ░░   ░░░░ ░░▒▒▒▒▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░░▒░▒▒▒░░░░▒░▒▒▒░▒░░   ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░ ░░░░░░▒░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒░░░░ ░░   ░ ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░░░░░ ░░░ ░░░░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░░░░ ░░   ░░░░░░░░ ░░░░░▒▒▓▓▓█████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░▒░░░▒▒░░░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░▒░▒▒░░░░░░░ ░░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░ ░░░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░ ░ ░░░ ░░░░░░░░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░ ░░░░░░░   ░░ ░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░ ░ ░ ░░░░░░░░░   ░░░░░▒░░░░░░░ ░░ ░░ ░ ░░░░ ░░░░ ░░░░░░░░░ ░   ░░░░░░░░░░░ ░ ░░ ░ ░ ░░░░░░░░░░░░░░░░░ ░ ░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░ ░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░░░░░░ ░░ ░░▒▒▒▒▓▒▒▒▒░░ ░░░▒▒▓▓█████▓▓▒▓▓▓▓▓▓▓▓▒░░ ░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░ ░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓████▓▓▓▓███▓▓▓▒▒░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░░░░ ░▒▒▒▒▓▓▓▓▒▒▒░░ ░ ░░▒▒▒▓▓▓▓▓▓▓███▓▓▓▒▒░░ ░ ░░▒▒▒▒▓▒▒▒▒░░ ░ ░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░ ░ ░ ░░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒░░░ ░ ░░░░░░▒▒▒▒░░░░░░░░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▓▓▒▒▒▒▒░░ ░░▒▒▒░░░░░ ░░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░░░ ░░░░▒░░▒▒░░░░▒░░▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░ ░░░▒░░░░░░░░░░░░▒▒▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░ ░░░░░▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░▒▒▒░▒░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░   ░ ░░░░░░░░░░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓██▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░▒▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░▒░░░ ░░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓█▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▒▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░░░░   ░ ░░░░░░ ░ ░░░ ░░░░░▒░▒░░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░   ░░░ ░ ░ ░ ░ ░░░ ░ ░░░░░░░   ░░░ ░░ ░ ░░░░░░░   ░░░░░ ░░ ░░░░░ ░ ░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░░ ░ ░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ░░ ░░░░ ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓▓▓▓▓▓▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▓▓██████▓▒▓▓███▓▓▓▒▒░░ ░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓██▓▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒░░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▓▒▒░░░ ░ ░░░▒▒▒▒▒▒░▒░░░░ ░ ░ ░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░ ░░░░░░▒▒▒▒░░░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒▒▒░░ ░ ░░░░░▒▒▒▒░░░░░░░░░░░░░   ░░░░ ░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░░ ░░░▒▒▒▒░░░░ ░░░░░▒░▒░░░░░░░▒░░░░░░░ ░   ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░░░░ ░░░░░░░░▒░░░░▒░░▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒░░░░░   ░ ░░░░░▒▒▒░░▒░░░▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░▒░▒▒▒░▒▒░▒▒▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░   ░░░░░░░ ░ ░░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░ ░░ ░   ░░░░░░░░ ░░░░░▒▒▓▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒░░░░ ░ ░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▒▒▒░░▒▒▒▒▒▒▒░░░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░░▒▒▒▒▒▒░░░░░░░░▒░░░░░ ░░ ░ ░ ░░░░░░   ░░░ ░ ░ ░░ ░░░░░░░ ░ ░░░░░░░░   ░░ ░░ ░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░ ░░░░ ░░░░ press ⏎ to skip ░░░░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ░░ ░░░░ ░ ░░▒▒▒▒▒▒▒▒░░ ░░░▒▓███████▓▓▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░ ░░░░░   ░░ ░░▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓██████▓▓▒▓▓▓██▓▓▓▒▒░░ ░ ░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░ ░ ░░▒▒▒▒▒▓▒▒▒░░░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓█▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░░░░ ░░ ░   ░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒░░ ░▒▒▓▒▒▓▓▓▓▓▓█▓▓▓▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▓▒▓▓▓▓▒▒▒░░ ░ ░░░░░▒▒▒░▒░░░░░░░░░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░▒▒▒▒▓▓▒▒▒░░░ ░░░░░▒▒▒▒░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒▒░ ░░▒▒▒▒▒░░░░ ░ ░░░░░▒░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒░▒░░ ░░░░░░░░░░░░▒░░▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░▒▒▒░▒░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒░▒▒░░░▒▒░▒▒▒░░░░░░░░░▒▒░░░░░░░░░░░▒▒▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░░░░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░ ░   ░░░░░░░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▒▓▓██▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░   ░ ░░░░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒░░░░░▒▒▒░░░░░░░░░░ ░ ░ ░░░░░░   ░░░░░░ ░ ░░ ░░░░ ░░░░░░░ ░░ ░ ░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░   ░░░ ░░░░ ░░░░░░░   ░░░░░░░░░░ ░ ░░░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░ ░ ░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░░░ ░ ░ ░░░░░░░░░░░░░░░░░░ ░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░░░░ ░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▓▓███████▓▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░   ░░ ░░░▒▒▒▒▒▒▒░░░ ░░░▒▓███████▓▓▒▓▓▓▓██▓▓▓▒▒░ ░ ░░▒▒▒▓▓▓▒▒▒░░░ ░░░░░ ░   ░░░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓▓▓█▓▓▓▓▓▓▓███▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░ ░ ░   ░░░░░ ░░░▒▒▒▒▓▒▓▒▒▒░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓██▓▓▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒░▒░░░ ░░░░░░░░░░▒░░▒▒▒▒▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒░░░░░ ░░░░░░░▒▒░▒░░▒▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░░░▒▒░░▒░░░░░ ░░░░▒▒░░░░ ░░░░░░░▒░▒▒░▒░▒▒▒▒▒▒▒░░░   ░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░   ░░ ░ ░ ░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░ ░ ░   ░░░░░ ░ ░░░░░▒▒▓▓████▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░░▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒░▒░░░░░░░░ ░░░ ░░░░░░   ░░░░░░ ░ ░░░░░░░░ ░░░░░░░░░░░ ░ ░░░░░░░░   ░ ░░ ░░░░ ░ ░░░░░░   ░ ░░ ░ ░░ ░░░░░░ ░   ░░░░░░ ░░░ ░ ░░░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░░ ░░ ░ ░░░░░░░   ░░░░░░░░░░ ░░░░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ✧ ✧ ✧ ████   ████   ░░░ ░ ░░░░░▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▓▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▒░░ ░ ░░░░   ░░ ░ ░░▒▒▒▒▒▒▒░░░ ░░▒▒▓███████▓▒▒▒▓▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▓▓▒▒▒▒░░ ░░ ░   ░░░ ░░░░▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░ ░░▒▒▒▒▒▒▒▒▒░░ ░   ░ ░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▓▓▓▓██▓▓▒▒▒░░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░ ░   ░░░░ ░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░ ░░░░░░▒▒░▒░░░░░░░░░ ░ ░   ░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▓▒▒▒▒░░ ░░░░░░▒▒░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒░░░ ░░░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒░░░░ ░░░░░░▒░░▒░░▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒▒▒▒░░░▒▒░░░░░ ░░▒▒░░░░ ░░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒░░░▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░▒░░   ░░ ░ ░░░░░ ░░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░░ ░░▒▒▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░ ░ ░ ░░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░░░   ░░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░ ░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░░░░░░░ ░ ░ ░░░░░   ░░░ ░░ ░░░░ ░ ░░░░░░   ░ ░ ░ ░░░░░░ ░   ░░░ ░ ░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░ ░ ░ ░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░   ░░░░░░░ ░ ░░░░ press ⏎ to skip ░░░░░░░░ ░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ████ ████ ✦   ████ ████   · ████ ████ · ·   ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ████   ░░ ░ ░░░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▒▒▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░▒▓▓▓▓▓▓▒▒░░ ░░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░░░▒▒▒▒▓▒▒▒▒░░ ░░ ░   ░░░░░▒▒▒▒▒▒▒░░ ░░░▒▒▒▓█████▓▓▓▓▓▓▓▓███▓▓▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▓▓▓▓▓▓▓▓▓▓▓███▓▓▒▒▒░░ ░░░░▒▒▒▒▒▒░░░░ ░ ░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒░░░ ░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░░░ ░░░░░░▒▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▓▒▒▒▒░ ░▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░ ░▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒░░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░ ░░▒▒░░░░ ░░░░░▒░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒▒▒▒░░░░░░░░▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░   ░░░░░░░░░░░░░▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░ ░   ░ ░ ░░░░░▒▒▓▓▓█▓▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░   ░░░░░░░ ░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▓▓▓▒▒▒▒░░░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░░░░░░░░░░░░░░░ ░ ░░░░░░   ░░░░ ░ ░░ ░░░░░░░ ░░░░░░░░   ░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░ ░░ ░ ░ ░░░░░░░░░░   ░░░░░░░░░░ ░░ ░░░░░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████   ✦ ████████████████████████✦ ✧  ✦ ████· ████   ▒▒▒▒ ████ ✧ ✧ ✦   · ▒▒▒▒ ████ · ✧   ░ ░░░░░▒▒▒▒▒▒▒░ ░▒▒█████████▓▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░▒▒▒▓▒▒▒▒░░ ░   ░ ░░ ░░░░░▒▒▒▒▒░░░ ░░░▒▒▓▓█████▓▓▒▒▒▓▓▓▓██▓▓▓▒░░░░ ░░░░▒▒▒▒▒▒▒░░░   ░ ░░░░▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▓▓▓▓▓▓████▓▒▒▒░░ ░░░▒▒▒▒▒░░░░░ ░ ░   ░░ ░ ░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░░░ ░░░░░░░▒░▒░░░░░░░░░ ░   ░░░░ ░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░▒▒▒▒▒▓▓▓▓▓▒▒▒▒░ ░░░░░░▒░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▓▓▓▒▒▒░░ ░░▒▒▒▒▓▒▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░ ░░░░░░░░░▒░▒▒▒▒▒░▒░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░ ░░░░░▒░▒░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒░░░░░▒░▒▒▒░▒░░░░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░   ░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░ ░   ░░░░░░ ░░░▒▒▒▓▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░░ ░░   ░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒░░░░░░░░░░▒░░░░░ ░ ░ ░░░░░   ░░░░░ ░ ░░░ ░░░░░░░░ ░░░░░░░░   ░░ ░ ░ ░░░░░░░   ░░ ░ ░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ ░░░░░░░░   ░░░░░░░░░░ ░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░ ░░░░░░ ░ ░░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░░░░░░ ░░░░░░░  [?7h[?25l[?7l · ✦   ·   · · ✧     ✦ ✦   ✦ ✧ ·   ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ░░ ░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░ ░░░░░░▒▒▒▒▒░░░ ░▒▒▓████████▓▒▒▒▒▒▓▓▓▓█▓▓▓▒░░ ░░░░▒▒▒▓▒▒▒▒░░░ ░   ░░ ░░ ░░░░░▒▒▒░░░░ ░░░▒▓▓██████▓▓▒▒▒▓▓▓▓██▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▓███▓▓▓▓▒▓▓▓████▓▓▒▒░░ ░░░▒▒▒▒▒░░░░░░ ░ ░   ░░ ░░░░▒▒▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▓▒▒▓▒▓▓██▓▓▓▒▒░░░ ░░░░░▒░▒░░░░░░░░░ ░░   ░░ ░░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒▒░░ ░▒▒▒▒▒▓▒▒▒▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒░▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░ ░░░▒░▒░░ ░░░░░▒░░░▒▒▒▒▒▒▒▒▒▒░   ░░░░▒▒▒▒░░░▒▒▒░▒▒▒░░░░░ ░░░░░▒▒▒░░░ ░ ░░░░░▒▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░ ░░ ░░ ░░░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░   ░░ ░░ ░░░▒▒▒▓▓▓██▓▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒░░░░░░░░ ░ ░░░░░░   ░░░░░ ░░░░░░ ░ ░░░░░░░░░ ░░░░░░░   ░░ ░ ░ ░░░ ░░░░░░   ░░ ░ ░ ░ ░ ░░░░░░░░   ░░░░ ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░ ░░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░░ press ⏎ to skip ░░░ ░░░░ ░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✧ ▒▒▒▒ ████ ✧   ░░ ░░▒▒▒▒▒▒▒▒▒░░ ▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▒▒▒░░░░ ░▒▒▒▓▓▓▓▓▒▒░░ ░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░░ ░ ░░▒▒▒▓▒▒▒▒▒░░░ ░   ░░ ░ ░░░░░░░░▒▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▓▓▓███▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░░   ░ ░░░░▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▓▒▒▓▓▓████▓▓▓▒▒░░ ░░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒░░░ ░░ ░░▒▒▒▓▒▓▓▓▒▒▒▒▓▓███▓▓▓▒░░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▓▒▒▒░░░ ░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░ ░░░▒░▒░░ ░░░░░░░░░▒▒▒▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░░ ░░░░▒▒▒░░░ ░░░░░▒░▒▒▒▒░▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░▒░░▒▒▒▒░░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░   ░░░░░░░ ░░░░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░ ░   ░ ░░░▒▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒░▒░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░ ░   ░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░▒░▒▒▒▒▒▒▒▒▒░▒░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒░▒░░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░ ░ ░░░░░   ░░ ░░░░░░ ░░░░░░   ░ ░ ░ ░ ░░░░░░░   ░ ░░░ ░░░░░░░   ░░░░░░░░░░ ░░ ░ ░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░   ░░░░░░░░░ ░ ░░░ ░ press ⏎ to skip ░░░ ░ ░░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ░░░░░▒▒▒▓▓▓▒▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒▒░░ ░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░ ░ ░░░▒▒▒▒▒▒▒▒▒░░   ░░ ░░ ░░░░▒░░░▒▒░░░ ░░▒▓▓███████▓▓▒▒▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░▒▒▒░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▓▓▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░▒░░░░░░░░░ ░░   ░ ░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░ ░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░░░▒▒▒░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░░▒▒░▒▒▒▒░░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░ ░░░░░▒░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒░▒▒▒▒░▒░░░░ ░░░▒▒▒░░░ ░░░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░▒▒▒░▒▒▒░░░░░░░░▒▒▒▒░░░ ░░░░▒░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░   ░░░░░░░░░░░░░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒░░░   ░░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░▒▒░░░░   ░░ ░░▒▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░░   ░░░░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░   ░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░ ░░░░░   ░ ░░ ░░░░░░░ ░░░░░   ░ ░ ░░░░░░░   ░░░ ░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░░░░░░░   ░░░░░░░░ ░ ░░░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░ ░ ░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▒▓████████▓▓▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░ ░   ░░░░░▒▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▓▓▓██▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░▒▒░░▒░░░ ░▒▒▓████████▓▓▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░▒▒▒▒▒▒▒░░   ░ ░ ░░░░░░░░▒▒▒░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░░░░░░   ░░ ░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▒▒▒░ ░ ░░░░▒░░░░░░░░░ ░░░   ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░░░   ░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒▒▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░░░ ░░░░░░░░░▒░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░▒▒▒░░░ ░ ░░░▒░░░▒▒░░▒▒▒▒▒░░░░   ░░░▒░▒░░▒░░░░░▒▒▒░░▒░░░░░░░░░▒▒▒▒░░░ ░░░░░░▒▒▒░▒▒▒▒▒▒▒▒▒░░   ░ ░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░   ░ ░░ ░░░░▒▒▒▓▓▓▓▓▓▒▓▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░ ░   ░░░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░░░░ ░░░░░   ░░░░░░ ░░░░░▒▒░▒░░░░ ░░░░░▒░░░░░░ ░ ░░░░░   ░ ░░ ░░ ░ ░░░░░░░ ░ ░░░░░   ░ ░ ░ ░░░░░░   ░░░ ░░░░░░░   ░ ░ ░ ░ ░░ ░░░░░░░   ░░░░░░░░ ░ ░░ ░░░░░░░░   ░░░░░░░░░ ░░░░░ press ⏎ to skip ░ ░░░░░░░ ░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ✧ ✦ ▒▒▒▒ ████   ✧ ▒▒▒▒ ████ ✧   ✦ ████· ✦   ████   ░░░░▒▒▒▒▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒░   ░░░░░▒▒▒▒▒▒▒▒░ ░▒▓████████▓▓▒▒▒▒░▒▒▒▓▓▓██▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░░░░░░░░▒▒▒░░▒░░ ░▒▒█████████▓▓▒▒▒▒▒▒▓▓▓███▓▒▒▒░░ ░░░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░▒▒░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▓▓███▓▓▓▒▒░░░ ░░░▒░▒░▒░░░░░░░░   ░ ░░ ░░░░░▒░░▒░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░░ ░░░ ░   ░░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▓▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░░░░░▒░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒▒▒░░ ░░░░░░░░░░▒░▒░▒▒▒░░   ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▒░░ ░░░░░░░░▒░░░▒▒▒▒░░░░   ░░░▒░▒░░▒░░░▒░▒▒▒░░░░░░░ ░░░▒▒▒▒░░░ ░░░░░░▒░░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░ ░░░░▒▒▒▓▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒▒░▒▒▒░▒░░░   ░░ ░░▒▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░   ░░░░ ░░░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓▒▒▒▒▒░░░░░░▒▒▒▒▒▒░▒░░░░░░░ ░░░░░   ░░░░░░ ░░░░░░▒▒▒▒▒░░░ ░░░░░▒░░░░░░░░░ ░ ░░░░░   ░░ ░░░ ░░░░░░ ░░░░░░░░░ ░░░░   ░ ░ ░░ ░░░░░   ░░ ░░░░░░░   ░ ░ ░ ░░░░░░   ░░░░ ░ ░ ░ ░░ ░░░░░░░░   ░ ░░░░░░░░░ ░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ████ ████ ·   · ✦ ████ ████ ✦   ████   ░░░▒▒▓▒▓▓▓▓▒░░ ▒▓▓████████▓▓▒▒▒▒▒░▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░░░▒▒▒▓▓▓▓▒▒░░   ░░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▓▒▒▒▒░░▒▒▒▓▓▓█▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░░   ░░░░░░▒▒░▒▒▒░░░░░ ░▒▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░░░░░░▒░░░░░ ░▒▓▓███████▓▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▓▓▓▓████▓▓▒▒░░░ ░░░░░░░░░░░░░ ░░░ ░   ░ ░░░░░░░▒░▒░░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▓▓▓▓██▓▓▒▒▒░░░ ░░░░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░ ░   ░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒░ ░░░░░░░░░░░░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░ ░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒░░ ░░░░░░░░░░▒▒▒▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒░░░ ░░░░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒░░▒▒▒░░░░░ ░░░░▒▒▒░░░ ░░░░░░░░▒▒░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░▒▒▒▒░░░░░░░░▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒▒▒▒▒▒░░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒░▒▒▒░▒░░░░   ░░ ░░░▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░   ░░░░░ ░░░░▒▒▒▓▓▓▓▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░   ░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░ ░░   ░░░░░ ░░░░░   ░ ░ ░░░░░   ░ ░░░░░░   ░ ░░ ░ ░ ░ ░░░░░░░   ░ ░░░░░░░░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ████████████████████████ ✦   ✧ ████████████████████████   ✦ ✦ ████ ████   ✦ ████ ✦████   ✦ ████· ✦   ████   ░░░▒▒▓▓▓▓▓▒▒░░ ░▒▓▓████████▓▓▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒░░░ ░░▒▒▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░▒▒▒▓▓▓█▓▓▓▒▒░░ ░░░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▓▓█▓███▓▓▒░░ ░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▒▒▓█████▓▓▓▒▒▒▒▒▒▓▓████▓▓▒▒░░░ ░░░░░░░░░░░░ ░░░ ░   ░ ░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▓▓▓▓██▓█▓▒▒░░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▓▓▓▓▒▒▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░▒░▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░░▒▒▒▒▒▒▒░░▒▒▒░░░ ░░░▒▒▒▒░░ ░ ░░░░░░▒▒░▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒░░░ ░░░░░▒▒▒▒░▒▒▒▒▒▒░▒░░░   ░░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░ ░ ░░▒▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▓▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░ ░ ░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒░░░░░░░░░ ░░░░   ░░░░░░ ░░░░░░▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░ ░░░░░░ ░░░░░   ░ ░ ░░░░░░   ░░░░░   ░ ░░ ░ ░░░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✦ ✦ ✧   · ✦ ✦ ✦ ✧ · ✦   ✧ ✦ ✦ ✧ ✧ · · ·   ·   ✦ ✦   ✧ ✦   ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▒▓▓▓▓▓▒▒░ ░▒▓▓███████▓▓▓▒▒▒▒▒░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▒▒▒░░   ░░░▒▒▒▒▒▒▒▒▒░░ ░▒▓▓███████▓▓▓▒▒▒░░░░▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░▒▒▓▓▓██▓▓▒▒░░ ░ ░░▒▒▒▒▒▒░░░   ░░░░░░░░░░░▒░░░░ ░░▒▓████████▓▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░▒░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░░   ░ ░░░░░░░▒░▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▓▓▓▓▓▒▒▒▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░ ░ ░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒░░░░ ░░░░░░░░░░░▒▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░░▒▒▒░░ ░░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒░░░ ░░░░▒▒▒▒░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒▒▒▒▒▒░▒▒▒░▒▒░░░   ░░░░ ░░░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒░▒░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░ ░ ░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░░░░   ░░░░░░ ░░░░░▒▒▒░░░░ ░░░░░░░░░░░░░ ░░ ░░   ░ ░░ ░ ░░░░░░░ ░ ░░░░░░   ░ ░░ ░ ░░░░░   ░░░░░░   ░ ░ ░░░░░   ░ ░░░░ ░ ░░ ░░░ press ⏎ to skip ░░░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   · ████ ·   ████   ░░░▒▓▓▓▓▓▓▒▒░ ░▒▓▓██████▓▓▓▓▒▒▒▒▒░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▒▓▒▒▒▒░   ░░░░▒▒▓▓▒▒▒▒░░ ░▒▓███████▓▓▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒▒░░░   ░░░░░░▒▒▒▒▒▒░░░ ░▒▓████████▓▓▒▒▒▒░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░▒▒▒▒░░░░   ░░░░░░░░▒░▒▒░░░░ ░▒▓▓████████▓▒▒▒▒▒░░▒▒▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░░ ░ ░░░░░░░░░░ ░░░ ░   ░░░░░░░░░░░░░░░░░ ░░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒░░ ░░ ░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▒▒░░░ ░░░░░░░░▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▒▒░░░ ░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░▒░░▒▒▒▒▒▒▒░░░   ░░░░░▒░░░░░▒░▒░░░░░░░░ ░░▒▒▒▒░░░ ░░░▒▒▒░░▒▒▒▒▒▒▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒░▒▒░░░▒░▒▒▒▒▒▒░░ ░░░░▒▒▒░▒▒░▒▒▒░▒▒░░░   ░░░░░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░▒▒▓▒▓▓▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░ ░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░ ░ ░░░░   ░░░░ ░ ░░░▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░ ░░ ░░   ░ ░░░░░ ░ ░░░░░░░░░ ░░░░░   ░ ░░░ ░░░░░   ░░░░░░   ░░░░   ░ ░ ░░ ░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▓▒░░ ░░▒▓▓█████▓▓▓▓▓▒▒▒▒▒░░░▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▒▒▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒░░░ ░▒▓███████▓▓▓▒▒▒▒░░░░░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▒░░░░░   ░░░░░░▒▒▒░▒▒░░░ ░▒▓█████████▓▒▒▒▒▒░░▒▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░░░ ░░░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▓▓▓████▓▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░░ ░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒░░░ ░ ░░░░░░░░░░░░░░░░   ░ ░░░░░░▒▒▒▒▒▒▒▒░░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░░▒▒▒░░░░   ░░░░▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒▒▒░▒░░░   ░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░▒░▒▒░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒░▒░░▒░▒░░░░░░ ░░▒▒▒▒░░░ ░░░▒░░░▒▒▒▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░▒▒▒░░░░░░░░░░▒▒▒▒▒▒░░ ░░░▒░▒░▒▒░▒▒▒░▒▒▒░░░   ░░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░   ░░░░▒▒▓▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░ ░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░▒░░░░░░░   ░░░░░░ ░░░▒▒▒▒▒▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░ ░░░   ░░░░ ░ ░░▒▒▒▒▒▒▒░░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░   ░ ░ ░░░░ ░░░░░   ░░░ ░░░░░░   ░░░   ░ ░ ░░ ░ press ⏎ to skip ░░░░░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ░░▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓███▓▓▓▓▓▓▒▒▒▒▒░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░ ░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░░ ░▒▓▓███████▓▓▓▒▒▒▒░░░░░░▒▒▓▓▓▓█▓▓▒▒░░ ░░░▒░▒▒▒░░░░   ░░░░░░▒▒▒▒▒▒░░░ ░░▒▓████████▓▒▒▒▒▒▒░░▒▒▒▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒░░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░░ ░ ░░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▓▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒▒░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░▒░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░ ░░░▒░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░▒▒▒░░░░   ░░░░░▒░▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░▒▒░░░ ░░░░░░░░░▒▒▒▒▒▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░░░ ░░▒▒▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒▒░▒░░   ░░░░░▒░░░░░░░░░░░▒░░░░░░░░░░░▒▒▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░ ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░ ░░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░▒░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░   ░░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒░▒░░░░░ ░░   ░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░   ░ ░ ░░░░░ ░░░░   ░ press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   ░░▒▒▓▓▓▓▓▒▒░ ░░▒▒▓▓▓█▓▓▓▓▓▓▓▒▒▒▒▒▒░░░▒░▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░░▒▒▒▒▒▒▒▒░░   ░░░▒▒▒▓▓▒▒▒▒░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒░░ ░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓▓▓█▓▓▒▒░░░ ░░░░░▒▒▒░░░░░   ░░░▒░░▒▒▒▒▒▒░░ ░▒▒▓████████▓▒▒▒▒▒▒░░░▒▒▒▓▓████▓▒▒░░░ ░ ░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░░░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░ ░░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░ ░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░▒▒▒▒▒▒░░ ░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░░░▒▒░░▒░░░   ░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░▒▒▒▒▒░░ ░░░░░░░▒▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒░░▒▒░░ ░░░░░░░▒▒▒▒▒▒░▒░░░   ░░▒░▒▒▒▒▒░▒▒░▒░░░ ░ ░░░▒▒▒░░░ ░░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░░▒░░░░░░░░░░░░░░░ ░░░░ ░░░░▒▒▒▒░░ ░░░░▒░▒▒░▒▒▒░▒▒▒▒░░░   ░░░░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░   ░ ░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░   ░░░░ ░░▒▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░ ░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒░░░░░░ ░   ░ ░ ░ ░░░░░░▒░░░░ ░░░░░░░░░░░░ ░ ░ ░   ░ ░ ░ ░ ░░░░░░ ░░░   ░░░░░ ░░░░░░   ░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ░▒▒▒▓▓▓▓▓▒▒░ ░▒▒▓▓▓▓█▓▓▓▓▓▒▓▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▓▓▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓████████▓▒▒▒▒▒▒░░░░░░░▒▒▓▓▓█▓▓▓▒░░░░ ░░░░▒▒▒░░░░░   ░░░▒░▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░▒▒▒▓▓███▓▓▒▒░░ ░░░░░░░░░░░   ░░░░░░░▒▒░░░░░░ ░░▒▓███████▓▓▓▒▒▒▒▒▒░▒▒▒▓▓█████▓▓▒░░ ░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░░ ░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒▒▒░░░ ░░░░▒▒▒▒░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░▒▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░▒▒▒▓▒▒▒▒░░ ░░░░░░░░▒░░▒░▒░░   ░░░░▒░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒░▒▒▒░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒░░ ░░░░░░░▒▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░░ ░░░▒░░▒░░ ░░░░░▒▒▒▒▒▒▒▒░▒░░░   ░░░░▒░▒▒░▒░░░░░░░░░░░░ ░░░░▒▒▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░▒▒░░▒░░░░░░▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒▒░░░░░░   ░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░ ░ ░ ░   ░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░░░░ ░   ░ ░ ░░░░░ ░░░░░░░░ ░ ░░   ░░░░░ ░░░░░░   ░░   press ⏎ to skip ░░░░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▓▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▓▒▒▒░░ ░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░▒▓███████▓▓▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒░ ░░▒▓████████▓▓▒▒▒▒▒▒░░░░░▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░   ░░░░▒░░▒▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒░▒░▒▒▒▒▓█████▓▓▒▒░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░▒▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░ ░░░░░░░░░░░░░░   ░ ░ ░ ░░ ░░░░░░░░ ░░░░░▒▒▒▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░ ░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░░ ░░▒░▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒▒░░░░░   ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░▒▒▒▓▒▒▒▒▒░ ░░░░░░░▒░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░ ░░░░░▒░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░▒░░▒░░░ ░░░░░░░▒▒▒▒░▒░▒▒░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░▒▒░░▒▒░▒▒▒▒▒░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒▒░░░░░░   ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░ ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒░░░ ░░░░░░░░▒░░░▒░░░░ ░   ░ ░ ░ ░░░░░░ ░░░░░░░░░░ ░░   ░░░░░ ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧ · ✦   ████████████████████████ ✧ ·✧   · ████ ████   ✦ ████ ████ ✧   ████ ████ ✦   ████ ████ ✦   ▒▒▒▒ ████ ·   ▒▒▒▒ ████ ·   ░▒▒▒▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓██████▓▓▒▒▒▒▒▒▒░░░░ ░░░▒▒▓▓█▓█▓▓▒▒░░ ░ ░░▒░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░░▒▓████████▓▓▒▒▒▒▒░░░░░░▒▒▒▓███▓▓▓▒▒░░░ ░ ░░░░░░░░░   ░░░░▒░▒▒▒░░░░ ░▒▓████████▓▓▒▒▒▒▒░▒░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░▒▒▓▓▓████▓▓▒▒▒▒▒▒▒▒▓▓██████▓▓▒░░ ░░░░░░░░░░░░░   ░░░░░░░ ░ ░░░░░░░ ░░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒▒▓▓▓▒▒▒░░ ░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░▒▒▒▒▒░▒░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒▒▒░░ ░░░░░░░▒▒▒▒▒▒▒░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░ ░░░░░░░░░▒▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░▒░░▒▒░▒▒▒▒▒░░░   ░░░░░▒░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒░░░░ ░░░░░░▒▒▒▒░▒▒░░░▒░░░   ░░░░░░░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▓▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░   ░ ░ ░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒░▒░▒░░░░ ░   ░ ░ ░░░░░░░░░ ░░░░░░░░░░ ░   ░░░░░░ ░░░░   ░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ✧   ✧ ✧ · · ✦✧   ✦   · ✦ ✧ ✦   · · · ·   · · ·✧ · ·   ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░▒▒▒▓▓▒▒▒░░ ░░░▒▒▒▓▓▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▓▓▒▒▒░ ░▒▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒░░░ ░░░▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒▒▒░░ ░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓█▓▓▒▒░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓███████▓▓▓▒▒▒▒▒░░ ░░░░▒▒▓▓▓█▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▓████████▓▒▒▒▒▒▒░░░░░▒▒▓▓▓███▓▓▓▒░░ ░ ░░░░░░ ░░░ ░░   ░░░░░░░░░░░░░░ ░▒▒▓███████▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░░░ ░░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒░░ ░░░░░░░░░░░░░   ░░░ ░ ░░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░▒▒▒▓▓▓▓▓▓▓▓▒░░ ░ ░░░░░▒░░░░░░░   ░ ░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒▒▒▓▓▓▓▒▒▒░ ░░░░░░░░▒▒▒░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░░▒░░░   ░░░░░░▒▒▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒░░░ ░░░░░░░░▒▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░▒░░ ░░░░░░▒░░░░ ░░░░░░░░░▒░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒▒░░ ░░░░░░▒▒░▒▒▒▒▒░░░░   ░░░░░▒░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░ ░░░░░▒▒▒▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░▒░▒▒▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒▒▒░▒░▒░░░░   ░ ░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░ ░░▒▒▒▓▒▒▒▒▒▒▒▒░░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░ ░   ░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░▒▒▒░▒░░░░░░░   ░ ░ ░░░░░▒░░░ ░░░░░▒▒░░░░ ░   ░░░░░░░░ ░░   press ⏎ to skip ░ ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░▒░▒▒▒▓▓▓▓▒▒▒░░ ░░▒▒▒▒▒▒▒░░░   ░░▒▒▒▒▓▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▓▓▓▒▒░░ ░░▒░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒▒░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓█▓▓▒▒░░░ ░░░░░░▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓██████▓▓▓▒▒▒▒▒▒░░ ░░░░░▒▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒░░░░░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░▒▒▓▓██████▓▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒▒░░ ░ ░░░░░░░░░░░   ░░░░░░░ ░ ░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░ ░░░░░░░░░   ░ ░░░ ░ ░ ░░░░░░░░░░ ░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░▒▒░░░ ░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░▒▒▒▒░░░ ░░░░░░░░▒▒▒▒▒░░░   ░░░▒░▒▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒░░░ ░░░░░░░░░▒░▒▒▒░░░   ░░░░▒▒▒▒▒▒░░░░░░░░░ ░░░░▒░▒░░░ ░░░░░░░▒░▒▒▒▒▒░░░░   ░░░░░░▒▒░░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒▒▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░▒░▒░░░░   ░░░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒░▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░ ░░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒░▒░░░░░   ░ ░ ░░░░░░░▒░░░░ ░░░░░▒▒░░░░░░   ░░ ░░░░░░░░░ ░░   ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ████   ░▒▒▒▒▓▓▒▒▒░░ ░░▒▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒░░   ░░▒▒▒▒▓▒▒░░░ ░░▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░░░░▒▒▒▒▒░   ░░░▒▒▒▒▒▒▒▒░ ░▒▒▓▓█▓█▓▓▓▓▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓█▓▓▓▒▒░░ ░░░░▒░░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒░░ ░ ░░░▒▓▓██▓▓▓▒▒░░░ ░░░░░░░░░   ░░░░▒▒▒▒▒░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒░░░ ░░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░▒▒▓██████▓▓▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▒▓███████▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▓▓▓▓████▓▓▒░░░ ░ ░ ░░░░░░░░░░   ░░░░░ ░ ░ ░░░░░░░ ░░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓█▓▓▒▒░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░░░░░░░▒░░▒░░▒▒▒▓▓▓▓▓▓▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▓▒▒░░░ ░░░░░░▒▒░░▒░░░   ░░░░░░▒░▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒░░░ ░ ░░░░░░▒▒░▒▒░░░   ░░░░▒░░▒▒▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░▒░▒▒▒░░░░   ░░░░▒▒▒▒▒▒▒░▒░░░░░ ░░░░▒░▒░░░ ░ ░░░░░▒░▒▒▒▒▒░░░░░   ░░░░░░▒▒▒░░░░░░░░░░░ ░░░░▒▒▒░░░░░ ░░░░▒░▒░▒▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░▒░░░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒░▒░▒░░░░░   ░░░ ░ ░ ░░░░░░▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░░▒▒▒▒▒▒░░░░░░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░░░░░   ░░░▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒░░░░░░ ░   ░ ░░░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒░░░░░░   ░ ░░░░░▒▒▒░░░░░ ░░░░░▒▒░░░░░░░   ░░░░░ ░░░░░░░░░ ░   ░░ ░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   · ▒▒▒▒ ████ ✧   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▓▒▒▒▒░▒░░░░▒▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░ ░ ░░▒▒▒▒▒▒░░   ░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▒▒▒▒▒░░░ ░░░░░▒▓▓▓▓▓▓▒▒░░░ ░░▒░▒▒▒░░░   ░░░▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░░░ ░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓█▓▓▒▒░░░ ░░░░░░░░   ░░░░▒▒▒▒▒░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓████▓▒▒░░░ ░░░░░ ░░░ ░░   ░░░░░░▒░░░░░░ ░▒▓▓███████▓▓▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▒▒░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░░▒▓███████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░ ░ ░░▒▒▓▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░░░   ░ ░░░░░░ ░ ░░ ░░░▒▒▒▒▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░ ░░ ░░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░░   ░░ ░░░░░░░▒░░░░ ░░░░░░░░░▒▒▒▒▓▓▓▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░▒▒░▒░░░   ░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒░░░ ░░░░░░░░░▒▒▒░░░░░   ░░░░░░▒▒▒▒▒▒▒░▒░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒▒░░░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░▒▒░░░░░░ ░░░░░▒░▒▒░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░ ░░░░░▒▒▒▒▒░░░ ░░░░░▒▒▒▒░▒░▒░░░░░░░   ░░░░░░░ ░░░░░░░░░░▒░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░   ░░░▒▒▒▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░░▒░░░░░░░░▒▒▒▒▒░▒░░░░ ░   ░ ░░▒▒▒▒▒▒▒▒░░░░ ░ ░░░░░▒▒▒░▒░░░░░   ░ ░░░░░░░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ████   ░░▒▒▒▒▒▒░░░ ░░░▒▒▓▓▓▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▒▒▒▒░░░ ░░▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▒▒▒▒░▒░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░▒▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒▒▒░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒░ ░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒░░░░ ░▒▒▓▓████▓▓▒▒░░ ░░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░░▒▓▓██████▓▓▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▒▒▒░░ ░ ░░░░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒░▒▒▒▓▓▓████▓▓▓▒▒░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓██████▓▒▒░░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▒▓▓▓▓▒▓▒▒▒▒▒▒▒▒▒▓▓▓▓▓██▓▓▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░ ░░ ░░░░░░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░▒░░░░░   ░ ░ ░ ░░░░░░░░░░ ░░░░░▒░░░░░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░▒▒▒▒▒░░░ ░░░░░░▒▒▒▒░░░ ░░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░▒▒░▒░░░░   ░░░░▒▒▒▒▒▒░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒▒░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░▒▒▒▒▒░░░ ░░░▒░▒▒░▒░▒░░▒░░░░   ░░░░░░░ ░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░▒▒▒▒▒░░░░▒░░░░░░   ░ ░░░░▒▒▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░ ░ ░░░░▒▒▒▒▒▒▒░░░░░░░░░   ░░░▒▒▒▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒░░░░░░░░░▒▒▒▒▒▒░░░░ ░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒░▒░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░   ░░░░░   press ⏎ to skip ░░ ░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   · ▒▒▒▒ ████ ✧   · · ████ ·   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒▒░░░░░░▒▒▒▓▓▓▓▒▒▒░░░ ░░░▒▒▒▒▒▒░░░   ░░░▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▒▒▒▒▒░░░░░░░░░▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓▓▓▓▒▒▒░░░ ░░░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓█▓▓▓▒▒░░ ░░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░▒▒▓█████▓▓▓▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓██▓▓▒▒░░ ░░░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓████▓▓▓▒▒░░ ░░░░ ░░░░░   ░░░░░░░░░░░░░ ░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░░▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▒▒▒░ ░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░ ░ ░ ░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░░ ░░░░░▒▒░░░░▒▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░░▒░░░░   ░░░░░░░░░░░▒░░░░ ░░░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░▒░░▒░▒▒░░░ ░░░░░░▒▒▒▒▒░░ ░░░░░░░░▒▒▒▒░░░░   ░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░▒▒░░▒░░░░   ░░░░░░▒▒▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░░ ░░░░▒░░▒░░░ ░░░░░▒▒░▒░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░ ░░░░░▒▒▒░░░░▒░▒░░░░   ░░ ░░░░▒▒▒▒▒▒▒░▒▒░▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒▒▒░▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒░▒▒▒▒▒▒▒░░░░░░░░░   ░░▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒░░░░░░░░░▒▒▒▒▒░▒░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░░░▒░░░░░ ░░░░░░░░░░░░   ░░ ░ ░░░░░░  [?7h[?25l[?7l ████████████████████████ ✧   ████████████████████████ ✦   ✧ ████ ████   ✦ ████ ████ ✧   · ████ ████ ✧   · · ████ ████ ·   ✧ ✦ ▒▒▒▒ ████ ✧   ░░░▒▒░▒▒░░ ░▒▒▒▓▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▓▓▓▓▒▒▒▒░░░░░░░▒▒▒▓▒▓▒▒▒▒░░ ░░░▒▒▒▒▒▒░░   ░░░░▒▒▒▒▒░░ ░ ░░▒▒▒▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒░░░░ ░░░░▒▒▒▓▓▓▓▒▒░░░ ░ ░░▒▒▒▒░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░░░░░   ░░░▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓█▓▓▓▓▒▒░ ░ ░░░░░   ░ ░░░░▒▒▒▒▒░░░ ░░▒▒▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓▓███▓▒▒▒░ ░░ ░░ ░░   ░░░░░░░▒░░░░░░ ░▒▒▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▓███████▓▓▓▒▒▒▒▒▒▒▒░░░▒▒▒▓▓████▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒░░ ░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒▒░ ░░░░░░░░░░░░░   ░░░░░ ░ ░░ ░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓██▓▓▒▒▒░ ░ ░░░░░░░░░ ░   ░ ░ ░ ░░░░░░ ░ ░░░░░▒░▒▒░░░░▒▒▒▒[0;38;5;170m▓▓▓▓▓▓▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒░░ ░░░░░░░▒░▒░░░   ░░░░░░░░░░░▒░▒░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒▒░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░░░░░░░░▒░░▒░░░░░   ░░░░░░░▒▒▒▒▒░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░░ ░░░░░░░▒▒▒▒▒░░ ░░░▒░▒░░░░▒░▒░░░░░   ░ ░░░░ ░░░░░░▒▒░░░░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░░░░   ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒░░░░ ░ ░ ░░░░░░░▒▒░░░░   ░░░░░░░░ ░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l   ·   ✦ ✧ ✧· ✧   · ✦ ✧ ✦   ✧   ✦ · ✧   ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ░░░▒▒░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░▒▒▒▓▒▓▒▓▒▒▒░ ░░▒▒▒▒▒▒░░   ░░░░▒▒▒░░░ ░░░▒▒▒▓▓▓▒▒▒░░░░░░░░▒▒▓▓▒▒▓▒▒▒▒░░░ ░░░▒▒▒▓▓▓▓▓▒▒░░ ░░░░▒▒░░░░   ░░░▒▒▒▒▒░░░ ░░▒▒▒▒▒▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░   ░░░▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▓▒▒░░░ ░░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓███▓▓▒▒░░░ ░ ░ ░░   ░░░░░░░▒░░░░░░ ░▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░ ░░░▒▒▓▓████▓▓▒▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▒▓████▓▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░░░ ░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░░ ░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░ ░ ░░░░░░░░░░   ░ ░ ░░░░░ ░░░░▒▒▒▒▒▒▒░░░░▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░▒░░▒░░░   ░░ ░ ░░░░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒░░ ░░░░░░░░░▒░░▒░░░   ░░░░░░░░▒▒▒▒▒░░░░ ░░░░░ ░░░▒░░░░ ░░░░░░░░░░▒░░░░░   ░░░░░░░░▒▒▒▒░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░▒░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░░░ ░░░░░▒▒░▒▒░░ ░░░▒░░░░░░▒░▒░░░░░   ░░░░░░ ░ ░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒▒░░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒░▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒░▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░▒▒▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒░▒▒▒▒░░░░ ░░ ░░ ░░░░░░░▒▒░░░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░▒░░░░░ ░░░▒▓▓▓▓▓▓▓▒▒▒░ ░ ░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░▒▒▒▒▒▒▓▒▒▒░░ ░░░▒▒▒▒▒░░   ░░░░▒░░░░░ ░░░▒▒▒▓▓▓▒▒▒░░ ░░░░░▒▒▓▓▓▒▓▒▒▒▒░░░ ░▒▒▒▓▓▓▓▓▒▒▒░ ░░░░▒▒░░░░   ░░░▒▒░▒▒░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░▒▒▒▒░░░░ ░░▒▒▓▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░░▒▓▓███▓▓▒▒▒░░ ░ ░ ░░   ░░░░░░░▒░░░░░ ░░▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▒▓▓███▓▓▓▒▒░░ ░░ ░ ░░░░   ░░░░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▒▓▓█████▓▓▒░░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒▒░▒▒▒▒▓▓████▓▓▓▒▒░ ░░░░░░░░░   ░░░░░░░░░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░░░ ░ ░░░░░░░░░   ░ ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▒▒░░░ ░░░░░░░░░░   ░ ░ ░░ ░░░░▒▒▒▒▒▒▒░░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░ ░░░░░░░▒░░░   ░ ░ ░░░░░░░ ░ ░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒░▒░░░░░   ░░ ░░░░░░░░░░░░░░░ ░░░░░░▒▒▒▒▒▒▒░░ ░░░░░░░▒░░▒░░░░   ░░░░░░░░▒░▒▒░░░░░ ░░░░░ ░░░▒░▒░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░ ░░░░░░░░▒░░░▒░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░▒░░▒▒░░░░   ░░░░░░░░░░░░░░░░░░ ░ ░░▒▒░░░░░ ░░░░░░░░░▒░▒░░░░░ ░   ░░ ░░░░░░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░ ░░░░▒▒▒▒▒▒░░░░░░░░   ░░░░░░▒▒▒▒▒▒░▒▒▒░▒▒▒▒▒░▒░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒░▒░░░   ░░░░░░░░░ ░░░░░░░░░   press ⏎ to skip ░░░ ░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ✦ ████ ·   ████   ░░░░░░░░░ ░░▒▒▓▓█▓▓▓▒▒▒░░ ░░░▒▒▒▓▓▓▓▒▒▒▒░▒░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░▒░▒▒▒░░░   ░░░░░░░░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░▒▒▓▓▓▒▓▓▓▒▒░░░░ ░░░▒▓▓▓▓▓▒▒▒░░ ░░░░▒▒░░░░░   ░░░▒░░▒░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░ ░░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒░ ░░░░░░░ ░   ░░░▒▒▒▒░░░ ░░▒▒▓▓▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓██▓▓▓▒▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓████▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▓▓███▓█▓▒▒░░ ░ ░ ░░░░   ░░░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒░░░░░░░▒▓▓▓████▓▓▒▒░░ ░ ░░░░░░ ░   ░░░░░░░░░░ ░▒▒▓▓██████▓▓▒▒▒▒▒▒▒▒▒░░░▒▒▒▓█████▓▓▓▒░░ ░░░░░░░░░   ░ ░░░░░░░░░ ░░▒▒▓▓████▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▓▓█████▓▓▒▒░░ ░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░ ░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░ ░░░░░░░▒░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▓▒▒▒░ ░░░░░░░▒░░░░░░   ░░ ░░░░░░░░░░░░ ░ ░ ░░░░░░░▒▒▒▒▒▒▒░░ ░ ░░░░▒░░▒░░░░   ░░░░░░░░▒░░▒░░░░░ ░░░░░ ░░▒▒░▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░░▒░░░░░ ░░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░▒▒▒▒▒░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░ ░░░░░░░▒▒▒▒░░▒▒▒░▒▒▒▒▒░▒░░░ ░░░░▒▒▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░░░▒░▒▒▒▒▒▒░░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░░░░░░ ░░░▒▒░▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░░░░░░░░ ░░░░░░░░░░   press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ✦ ████ ████ ·   ████ ████ · ·   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ✦ ████ ·   ████   ░░░░░░░░░ ░░▒▓▓▓█▓▓▓▒▒▒░ ░░░▒▒▒▓▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░   ░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▓▒▒░░░ ░░░▒░░░░░   ░░░░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▓▓▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░░ ░   ░░░░▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓█▓▓▓▓▒░░ ░ ░   ░░░░░░░▒░░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓██▓█▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓█████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░ ░▒▒▓███████▓▓▒▒▒▒▒▒▒▒▒░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▓▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓██▓▓▓▓▒░░ ░░░░░░░░░   ░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒░░░ ░░░░░░░▒░░░░   ░ ░ ░░░░░ ░░░░░░░░░░░▒░▒▒▒▒▒▓▓▓▒▒▒▒░ ░░░░░░░░░░░░░   ░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░▒░░░░   ░░░░░░░░░░░░▒░░░░ ░░░ ░ ░░▒▒▒▒▒░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░░ ░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░░░▒░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░ ░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░▒▒▒▒▒▒░░░░░░░░░░   ░░ ░░░░░░░░▒▒░░░░▒▒░▒▒▒▒▒░▒░░░ ░ ░░▒░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░ ░░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░▒▒▒▒▒▒▒▒▒▒▒░░░░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░▒▒▒▒▒▒▒░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░   ░░░░▒▒░░░░░ ░░░░▒░░░░░░   ░░ press ⏎ to skip ░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ████ ████   ✦ ████ ████   ▒▒▒▒ ████ · ·   ▒▒▒▒ ████ ✦   ░░░░░░░ ░░▒▒▓▓▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░▒▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒▒░░░░   ░░░░░░░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒░░░ ░░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░░░ ░░▒▒▒▓▓▓▒▒▒▒░░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░░▒▒▒▓▓▓▓▓▒▒▒░░ ░░░░░░░ ░   ░░░▒░░░░░ ░░░▒▒▒▓▓▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░▒░░▒░░░ ░░▒▒▒▒▒▒▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓█▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░ ░░░▒▓▓████▓▓▒▒░░ ░ ░░░░   ░░░░░░░░░░░░ ░░▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░▒▓▓▓▓██▓▓▓▒▒░░ ░░ ░░░░░░   ░░░░░░░░░░ ░▒▒▓██████▓▓▓▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓████▓▓▒▒░░░ ░░░░░░░   ░░░░░░░░░░░ ░░▒▒▓█████▓▓▒▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓████▓▓▓▒▒░░ ░░░░░░░░   ░░░░░░░ ░ ░░▒▒▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▒▒▒▒▒▒▒░░▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░░ ░░ ░░░░▒░░░░   ░ ░ ░ ░ ░░░ ░░░░▒▒░░░░░░▒░▒▒▒▒▒▓▓▓▓▓▒▒░ ░░░░░░░░░░░░░   ░ ░ ░ ░░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░   ░░ ░░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░▒▒▒▒░░░░ ░░░░░░░░░░░░░ ░ ░░░░░░░░░▒░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░░░░░▒░░░░░░   ░ ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░▒░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░░░░░░▒░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░▒▒▒▒▒▒▒░░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒░▒░░░░ ░░░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░   ░░▒▒▒▒░░░░░ ░ ░ ░ ░░░░▒░░░░░░░   ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░░░░░░░ ░░▒▒▓█▓█▓▓▓▒▒░░ ░░▒▒▒▓█▓▓▓▒▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒░░░ ░░░░▒▒░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒░░░░ ░░░▒▒▓▓▓▓▒▒▒░░░ ░░░░░░░░░   ░░░░░░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░▒▒▒▓▒▒▒▒▒▒░░░ ░▒▒▒▓▓▓▓▒▒▒░░ ░░░░░ ░   ░░░░░░░░░ ░░░░▒▒▓▓▓▒▒▒░░░ ░ ░░▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░ ░ ░   ░░░░▒░░░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░▒░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓█▓█▓▓▓▒▒░ ░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓████▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▓▓▓███▓▓▒▒▒░ ░ ░░░░░░   ░░░░░░░░░░ ░░▒▓▓██████▓▓▓▒▒▒▒▒▒▒░▒░░░░░▒▒▓▓█████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓█████▓▓▒▒▒░ ░░░░░░░   ░░░░░░░ ░░▒▓▓▓█▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓████▓▓▒▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▓▓▓▓▓▓▓▓▒▒░ ░ ░░░░░░░░░   ░░ ░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░▒░░░░░   ░░ ░░░░░░░░░░░░░ ░░░ ░░░░▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░░ ░░░░░░░░░▒░░░ ░░░░░░░░░▒░▒░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░▒░▒░░░░░░░░   ░░░░░░░░░░░░░░░░░░░ ░░░░░░▒░░░░░ ░░░▒▒░▒░░░░░░░░░░   ░░░░░ ░░ ░░░░░░░░░░░░░░░░░░░▒▒▒▒▒░░░░░ ░░░░░▒▒▒▒░░░░░░░░   ░ ░░░░▒▒▒▒▒░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒▒▒░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░   ░░░▒▒▒▒░░░░░ ░░░░ ░░ ░░░░▒░░░░░░░   ░ ░░░░ press ⏎ to skip ░░░░░░░  [?7h[?25l[?7l ✧ ✦ ✧ ████████████████████████   ████████████████████████ ✧ ✦   ░ ░░░░░░ ░▒▒▓▓███▓▓▓▒░░░ ░░▒▒▒▓███▓▓▒▒▒▒▒░░░░░░░▒▒▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░░ ░░▒▓▓▓▓▓▓▓▓▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▒▒▒░░░ ░ ░▒▒▒▓▓▓▓▓▒░░░ ░░░░░░░░░░   ░░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒▒░ ░░░▒▒▒▓▒▒▒▓▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▒▒▓▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒░▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓██▓▓▒▒░ ░ ░   ░░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▒▒▒░ ░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▓▓████▓▓▒▒░░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓████▓▓▒▒░ ░░░░░░░   ░░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒▒░▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░ ░░░░░░░░   ░ ░░ ░ ░░░░░▒▒▒▒▒░░▒░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░   ░ ░░░░░░░ ░░░░░░░░░░░░░░▒▒▒▒▒▓▓▒▒▒░░ ░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░▒▒░░░ ░ ░░░░░░░▒▒░░ ░░░░░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░▒░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░░░░   ░░░░░ ░░░░░░░░░░░░░░░░░ ░░░░▒▒▒▒░░░░░ ░░░░▒▒▒▒░▒░░░░░░   ░ ░ ░░░░▒░▒░░░░░▒▒░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░░░░░░░   ░░░░░▒▒▒▒▒▒▒▒▒▒▒░▒▒▒▒░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░ ░   ░░░░▒▒▒▒▒▒▒▒▒░░░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░ ░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░░░░░░░   ░░░░░░ press ⏎ to skip ░░░░░░░░  [?7h[?25l[?7l ░░░░░░░░░ ░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▒▒▒▒░░░░░░░░░▒▒▓▓▒▒▒▒░░ ░░░░▒░▒░░   ░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▓▓▓▓▓▓▓▓▓▒▒▒░░ ░▒▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░   ░░░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▒▓▓▓▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░ ░░░░░░   ░░░░░░░ ░▒▒▒▓▒▓▓▒▒░░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░   ░░░░░░░░░░░░ ░░░▒▒▒▓▓▓▒▒▒▒▒░░▒▒▒▒▒▒▒░░░░ ░▒▒▓▓▓██▓▓▓▒▒░ ░   ░░░░░░░░░░░ ░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░ ░░▒▒▓▓▓███▓▓▒▒░░ ░░░ ░░   ░░░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓█▓███▓▓▒▒░ ░░░░░░░ ░   ░░░░░░░░░░░ ░░▒▒▓▓█████▓▓▒▒▒▒▒▒▒▒▒▒░░░▒▒▒▓▓▓▓████▓▒▒░░ ░░░░░░   ░░░░░░ ░░░▒▓▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓███▓▒▒░░ ░░░░░░░░░░   ░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░ ░ ░░░░░▒▒▒▒▒▒▒░▒░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒░░░ ░░░░░░░░░░░   ░ ░░ ░░ ░░░░░░░░░░░░░░░▒▒▒▒▒▓▓▓▒▒▒░░░ ░░░░░░░░░░░░   ░░ ░░ ░░░░░░░░░ ░░ ░░░ ░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░░   ░ ░░░░░░░░░▒░░░ ░ ░░░░░░░▒▒░░░ ░ ░░░░░░░▒░░░   ░░░░░░░░░░░░░░░░ ░░░ ░░░░░░ ░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░ ░░░░░▒▒░░░░░░ ░░░░░▒▒▒░▒░░░░░░   ░░ ░ ░░░░▒░▒░░░░░░░░▒▒▒▒▒▒░░░ ░░░░▒▒▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒▒▒▒▒░▒▒▒▒▒▒▒░░░░ ░░░░░░░▒▒▒▒▒▒░░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░░▒░░░░░░░░░░░▒▒▒▒▒▒▒░░░░   ░░▒▒▒▒▒░░░░░░░░░░░░░ ░░░░▒░▒░▒░░░░░   ░░░░░░░ press ⏎ to skip ░░░░░░░░░  [?7h[?25l[?7l ✧   ✦ ✦ ✧     · · · ·   ✦ ✧ · ✧   · ████████████████████████ ✧   ████████████████████████ ✧   ✦ ✧ ✦ ████ ████ ·   ████ ████   · ✧████ ████ · ✧ ✦   ✧ · ████ ████   ✧ ▒▒▒▒ ████   ▒▒▒▒ ████ ✧   · ████   ░░░░░░░ ░░▒▒▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▒▓▒▒▒░░░░░░░░░▒▒▒▓▒▓▒▒▒░░ ░░▒░▒░░   ░░░░ ░░▒▒▓▓██▓▓▓▒▒░ ░░░▒▒▓▓▓▓█▓▓▓▓▒▒▒░░ ░▒▒▒▓▓▓▓▒▒░░ ░░░░░░░░░   ░░░░ ░░▒▒▓▓▓▓▓▓▒░░░ ░░░▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░▒▓▓▓▓▓▓▒▒░░░ ░░░░░░   ░░░░░ ░ ░░▒▒▒▓▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░ ░░░░░░▒▒▒▒▒▒░▒░░░ ░░▒▒▓▓▓▓▓▓▒▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▒▓▓▒▒▒▒░░░░░▒▒▒▒▒▒░░░░ ░░▒▓▓▓███▓▓▒▒░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓███▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓███▓▓▒▒▒░ ░░░░░░░░░   ░░░░░░░░░░░ ░▒▒▓▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▓▓▓████▓▓▒▒░░ ░░░░░░   ░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓█▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░░▒▒▒▒▒▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░ ░░░░░░░░   ░ ░░░░▒▒░░░░░░░░▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░░░░   ░ ░ ░░░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░░░▒▒▒░░░ ░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░░░ ░░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░░ ░░░░░▒░░░░░░░ ░░░▒▒▒░▒░░░░░░░░   ░░░ ░ ░ ░░░░░░░░░░░░░░░░░▒▒▒▒▒▒░░░ ░░▒░▒▒▒░▒░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░░░░░░▒▒▒▒▒▒▒░░░░░   ░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l ████████████   ██ ██   ░░░░░░░ ░░▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒░░░░░░░░░▒▒▒▒▒▓▒▒▒░░░ ░░░░▒░░░   ░░░ ░░▒▓▓▓██▓▓▓▒░░ ░░░▒▒▓▓▓▓██▓▓▓▒▒▒░░░ ░░▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░ ░░▒▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░▒▒▓▓▓▓▓▓▒▒░░ ░░░ ░   ░░░░ ░ ░▒▒▒▒▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░▒▒▓▓▓▓▓▓▒▒░░ ░ ░   ░░░░░░░░░ ░░░░▒▒▒▒▒▒▒░░░ ░░░░░▒▒▒▒▒▒░▒░░░ ░▒▒▓▓▓▓▓▓▒▒░░░   ░░░░░░░░░░░ ░░░▒▒▒▒▒▒▒▒░░░░░░░▒▒▒▒▒░░░░░ ░▒▒▓▓███▓▓▒▒░░ ░   ░░░░░░░░░░░ ░░▒▒▒▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓█▓█▓▓▓▒▒░ ░ ░░   ░░░░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▒▓▓███▓▓▒▒▒░ ░░ ░░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▓▓▓███▓▓▓▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓███▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓███▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░░░░   ░░░░░ ░ ░░░▒▒▒▒▓▓▒▒▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░   ░░░▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒░ ░░░░░░░░░░   ░ ░░░░░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒░░ ░░ ░░░░░░░░░░░   ░ ░░░░░░░░░░░ ░ ░░░░░▒░▒▒▒▒░░ ░░░░░░░░░░░░░   ░ ░ ░░░░░░░░░░░░ ░░░ ░░░░░░░░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░▒░░░░░░░ ░░░░▒▒░▒░░░░░░░░   ░░░░░ ░░░ ░░░░░░░░░░░░░░░░░░▒░▒▒▒░░░ ░░░░▒▒▒░▒▒░░░░░░░   ░░░░░▒▒▒▒░░░▒░▒▒▒▒▒▒▒░░░░ ░ ░░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒▒▒▒▒▒░░░░   ░░░▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░▒▒▒▒░░░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░░░░░  [?7h[?25l[?7l ██ ██   ▒▒ ██   Setup step 1 of 5   Sign in to one or more providers. Press ⎋ when you're done.   ░░░░░░░ ░▒▒▓▓████▓▓▒▒░░░ ░░▒▒▓▓███▓▓▓▓▒▒▒▒░░░░░░░░░▒▒▒▒▓▒▒▒▒░░ ░░░░▒░░░   ░░░ ░▒▒▓█▓██▓▓▒▒░░ ░░░▒▒▓▓▓████▓▓▓▒▒▒░░ ░░▒▒▒▓▓▓▒▒▒░░░ ░░░░░░░░░   ░░ ░░▒▓▓▓▓▓▓▓▒▒░░ ░░░▒▒▒▓▓▓▓▓▓▓▒▒▒░░ ░░▒▒▓▓▓▓▓▒▒░░ ░ ░ ░   ░░░░ ░░░▒▒▓▓▓▓▓▓▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▓▓▓▓▓▓▓▒░░░ ░   ░ ░░░░░ ░░░▒▒▒▓▒▒▒▒░░░ ░░░▒▒▒▒▒▒▒░▒░░░ ░▒▒▓▓▓▓▓▓▓▒▒░░   ░ ░░░░░░░ ░░▒▒▒▒▒▒▒▒▒░░░░░░░░▒▒▒▒▒░░░░░ ░░▒▒▓████▓▓▒░░   ░░░░░░░░░░ ░▒▒▒▒▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓▓██▓▓▓▒░░░ ░░   ░░░░░░░░░░ ░░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒░░░░ ░░▒▒▓▓▓██▓▓▓▒▒░░ ░░░░░   ░░░░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▓▓▓███▓▓▒░░░ ░░░░░░░   ░░░░░░░░ ░░▒▒▓▓▓██▓▓▓▒▒▒▒▒▒▒▒▒▒▒░▒░▒▒▒▓▓▓▓██▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░ ░░▒▒▒▓▓█▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓█▓▓▓▒░░ ░░░░░░░░░   ░░░░░░ ░░▒▒▒▒▒▓▓▒▓▒▒▒▒░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░ ░░░░░░░░ ░   ░ ░░░░▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒░░ ░░░░░░░░░░░░   ░░░░ ░ ░░░░░░░░░░░▒▒▒▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░░░░   ░ ░ ░░░░░░░░░ ░ ░ ░░░░░▒░▒▒▒▒░░ ░░░░░░░░░░░░░   ░ ░░░░░░░░░░░░ ░░░░░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░ ░░ ░░░░░░░ ░░░░░░░░░░░░░░ ░   ░░░░░░░░░░░░░░░░ ░░░░░ ░░░ ░░░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░▒░▒░░░░░░░░   ░░░░░░ ░░░ ░░░░░░░░░░░ ░░░░░▒░▒▒░░░░ ░░▒▒▒░▒▒░░░░░░░   ░░░░░░░░░░░░░░▒▒▒▒▒▒░░░░░ ░░░▒▒▒▒▒▒░░░░░   ░░░░░▒▒▒▒▒▒▒░▒░▒░▒▒░░░░░░░░░░░░▒░▒▒▒▒░░░░   ░░▒▒▒▒▒▒░▒░░░░░░░░░░░ ░░░░░░░▒▒▒▒░░░░   ░░░░▒▒░░ press ⏎ to skip ░░░░▒░▒░░░  [?7h[?25l[?7l ██   omp   Sign in to your providers     Pick a provider to sign in — you can connect more than one.   │ ❯ ChatGPT Plus/Pro (Codex Subscription) █ │   │ Anthropic (Claude Pro/Max) │ │   ░░░░░ ░▒▓▓███▓▓▓▒▒░░ ░░░▒▒▓▓▓█████▓▓▓▒▒▒░ ░░▒▒▒▒▓▓▒▒▒░░ ░░░░░░░   ░ ░░▒▒▓▓███▓▓▒░░ ░░░▒▒▒▓▓█▓▓▓▓▓▒▒▒░░ ░▒▒▓▓▓▓▓▒▒░░░ ░ ░ ░   ░░▒▒▓▓▓▓▓▓▒▒░ ░░▒▒▒▒▒▒▒▒▒▒▒▒▒▒░ ░░▒▒▓▓▓▓▓▒▒░░ ░   ░ ░ ░░▒▒▒▓▓▓▒▒▒░ ░░▒▒▒▒▒▒▒░▒▒▒░░ ░▒▒▒▓▓▓▓▓▒▒░░░   ░░░░ ░░░▒▒▒▒▒▒▒▒░░ ░░░░▒▒▒▒▒░░░░░░ ░░▒▒▓▓██▓▓▓▒░░   ░░░░░░░░░ ░░▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▒▒░░░░░ ░░▒▓▓██▓▓▓▒▒░░ ░░ ░   ░░ ░░░░░░░ ░░▒▒▒▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░ ░░▒▒▒▓▓██▓▓▓▒▒░░ ░ ░░   ░░░░░░░░░░░ ░▒▒▓▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒░░░░░░░░░▒▒▒▓▓███▓▓▓▒▒░ ░ ░░░░░░   ░░░░░░░░ ░░▒▒▓▓█▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░▒░░░▒▒▓▓▓▓█▓▓▓▓▒▒░░ ░░░░░░░   ░░░░░░░░ ░░▒▓▓▓██▓█▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓█▓█▓▓▓▒▒░░ ░░░░░░░   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▒▒░░ ░░░░░░░░   ░░░░ ░░░░░▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░ ░░░░░░░░░░░░░░▒░▒▒▒▒▒▒▓▓▒▒▒▒░░ ░░░░░░░░░░░   ░░░░░ ░░░ ░░░░░▒▒▒▒▒▒▒▒░░ ░ ░░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░ ░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░░ ░░░░░░░░ ░░░░ ░░░░░▒░░░░░░░░░   ░░░░░░░ ░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░░░░░░▒▒░░░░░░░   ░ ░░░░░░░░░░░░░░▒▒░▒▒░░░░░░ ░░░░▒▒▒▒▒░░░░░░   ░░░░░▒▒░░░▒░░▒░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒░░░  [?7h[?25l[?7l     ╭─ Select provider to login ───────────────────────────────────────────────────────────────────────────────────╮   │ Z.AI (GLM Coding Plan) │ │   │ Z.AI (GLM Coding Plan · Sign in) │ │   ░░░▒▓▓▓▓▓▓▓▒░░ ░▒▒▒▒▒▒▓▓▓▒▒▒▒▒▒░ ░▒▒▓▓▓▓▓▒▒▒░ ░   ░░▒▒▒▒▓▓▓▒▒░░ ░░▒▒▒▒▒▒▒▒▒▒▒░░ ░░▒▒▓▓▓▓▓▓▒▒░░   ░ ░░▒▒▒▒▒▒▒▒▒░░ ░ ░░▒▒▒▒▒░░░░░░░ ░▒▒▓▓███▓▓▒▒░░   ░░░░░ ░░░▒▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒░░░░░ ░░▒▒▓▓▓▓▓▓▓▒░░░ ░░ ░   │ Devin │ │   │ Antigravity (Gemini 3, Claude, GPT-OSS) │ │   ░░░░░░░░ ░▒▒▒▓▓▓▓▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒░░░░░▒▒▒▒▓▓█▓██▓▓▒▒░░ ░ ░░░░░   ░░░░░░░░ ░▒▒▓▓███▓█▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓█▓▓▓▒▒▒░░ ░░░░░░░   ░░░░░░ ░▒▒▒▓▓▓▓▓▓▓▒▒▒▒▒░▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▒▒░ ░░░░░░░   ░░░░ ░░░░▒▒▒▒▒▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░░░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒░░ ░░░░░░░░░   ░░░░ ░░ ░ ░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░ ░░░░░░░░░   ░░░░░░░░ ░░░░░░▒░░░░ ░░░░░░░░░░░░░   ░░ ░░░░░░░░░░░░ ░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░ ░░░░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░ ░░░░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░░▒░░░░░░░ ░ ░░░░▒▒░░░░░░░   ░ ░░ ░ ░░░░░░░░░ ░░░░░░░▒░░░░░░ ░░░▒▒▒▒░░░░░░ ░░   ░░░░░░▒░░░░░░░░▒▒▒▒░░░░░░ ░░░▒▒▒▒▒▒░░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░ ░░░░░▒▒▒▒░░░░░   ░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒▒▒▒░░░ ░  [?7h[?25l[?7l │ Kimi Code │ │   │ OpenRouter │ │   │ GitHub Copilot │ │   │ Cursor (Claude, GPT, etc.) │ │   │ Type to search │   ╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────╯   ░░░░░░ ░░▒▒▓▓▓▓▓▓▓▓▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▓▓▓▓█▓▓▓▓▒░░ ░░░░░░░   ░░░░ ░░░▒▒▒▒▓▓▒▒▒▒░░░░▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▓▒▒▒░ ░░░░░░░░░   ░ ░░░░░▒░▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▒▒▒▒▒░ ░░░░░░░░░   ░░░░ ░░░░░░░░░░░░░▒▒▒▒▒▒▒▒▒░░░ ░░░░░░░░░░   ░░░░░░ ░ ░░░░░▒▒░▒░░░ ░░░ ░░░░░░░░░   ░░ ░░░░░░░░░░░ ░░░░░░░ ░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░ ░ ░ ░░░░ ░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░░ ░░░░░░░░░░░░ ░░░░░▒░░░░░░░░░   ░ ░░ ░ ░░ ░░░░░░░░░ ░░░░░░░░░░░░░ ░░░▒░▒▒░░▒░░░░░░   ░░░░░░░░░░░░░░░▒▒▒▒░░░░░░ ░░░░▒▒▒▒░▒░░░░░   ░░░▒▒▒▒▒▒░░░░░░░▒░░░░░░ ░░░░░▒▒▒▒░▒░░░   ░░░░▒░▒▒▒░ press ⏎ to skip ░░░░▒░▒▒▒░░░░  [?7h[?25l[?7l     ░ ░░ ░░░▒▒▒▒▒▒▒▒░░░░░░▒▒▒▒▒▒▒▓▓▓▓▓▒▓▒▒░░ ░░░░░░░░   ░ ░░░░ ░░░░░░░░░░░░▒░▒▒▒▒▒▒▒▒▒▒▒░░ ░░░░░░░░░░       ░░░░░░░░░░░░░ ░░░░ ░ ░░░░░░░░░░░░   ░ ░░░░░░░░░░░░░░ ░ ░░░ ░ ░ ░░░░░░░░░░░░░░   ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░░░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░ ░░░░░░░░░░░░ ░░░░▒▒░▒▒░░░░░░     ░░░░░▒▒▒▒░░░░░░░░▒░░░░░░ ░░░░▒▒▒▒▒▒░░░   ░░░░▒░▒▒░░ press ⏎ to skip ░░░░▒░▒▒▒░░░░░  [?7h[?25l[?7l         ░░░░░░░░░░░░░░ ░ ░░░░░░░░░░ ░ ░░░░░░░░░░░░░░░   ░░░░░░ ░ ░░ ░░░ ░░░░░░░░░░ ░ ░░░▒▒░▒▒░░░░░░░░   ░░░░░▒▒░░░░░░░░░░▒░░░░░░ ░ ░░░░▒▒▒▒░░░░░   ↑/↓ select · ⏎ confirm · ⎋ skip · ⌃C exit setup  [?7h[?25l[?7l     ░░░░░░░░░░░░░░░░░▒░░░░░ ░░░░▒▒▒▒▒░░░░  [?7h[?25l[?7l  [?7h \ No newline at end of file diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@agent.json new file mode 100644 index 00000000000..d17f5cf724e --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@agent.json @@ -0,0 +1,36 @@ +{ + "description": "codex recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-21: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "22-36: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "37-44: now=working edge=working quiet=ready-strong wait=ready@start", + "45-54: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "55-60: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "61-111: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "112-118: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "119-121: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "122-123: now=working edge=working quiet=ready-strong wait=ready@start", + "124-180: now=working edge=working quiet=working wait=pending", + "181-212: now=working edge=working quiet=ready-strong wait=ready@start", + "213-435: now=working edge=working quiet=working wait=pending", + "436-500: now=working edge=working quiet=ready-strong wait=ready@start", + "501-502: now=working edge=working quiet=working wait=pending", + "503-507: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "508-547: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "548-551: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "552-558: now=blocked:agent-interactive-prompt edge=blocked:agent-interactive-prompt quiet=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-596: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "597-601: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-36: verdict=pending:closed wait=pending", + "37-44: verdict=working wait=pending", + "45-121: verdict=pending:closed wait=pending", + "122-502: verdict=working wait=pending", + "503-551: verdict=pending:closed wait=pending", + "552-558: verdict=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-601: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@unknown.json new file mode 100644 index 00000000000..29bebffabb2 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--codex-0-160-0-plan-implement-menu@unknown.json @@ -0,0 +1,35 @@ +{ + "description": "codex recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-36: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "37-44: now=working edge=working quiet=ready-strong wait=ready@start", + "45-54: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "55-60: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "61-111: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "112-118: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "119-121: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "122-123: now=working edge=working quiet=ready-strong wait=ready@start", + "124-180: now=working edge=working quiet=working wait=pending", + "181-212: now=working edge=working quiet=ready-strong wait=ready@start", + "213-435: now=working edge=working quiet=working wait=pending", + "436-500: now=working edge=working quiet=ready-strong wait=ready@start", + "501-502: now=working edge=working quiet=working wait=pending", + "503-507: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "508-547: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "548-551: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "552-558: now=blocked:agent-interactive-prompt edge=blocked:agent-interactive-prompt quiet=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-596: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "597-601: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-36: verdict=pending:open wait=ready@poll", + "37-44: verdict=working wait=pending", + "45-121: verdict=pending:closed wait=pending", + "122-502: verdict=working wait=pending", + "503-551: verdict=pending:closed wait=pending", + "552-558: verdict=blocked:agent-interactive-prompt wait=blocked:agent-interactive-prompt@start", + "559-601: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@agent.json new file mode 100644 index 00000000000..105aa058311 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@agent.json @@ -0,0 +1,15 @@ +{ + "description": "omp recording at 60x24 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-303: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "304-305: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "306-364: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "365-707: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-303: verdict=pending:closed wait=pending", + "304-707: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@unknown.json new file mode 100644 index 00000000000..39c61da1329 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer-narrow@unknown.json @@ -0,0 +1,15 @@ +{ + "description": "omp recording at 60x24 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-303: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "304-305: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "306-364: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "365-707: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-303: verdict=pending:open wait=ready@poll", + "304-707: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@agent.json new file mode 100644 index 00000000000..b217a84a268 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@agent.json @@ -0,0 +1,19 @@ +{ + "description": "omp recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-510: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "511: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "512-570: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "571-587: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "588-25926: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "25927-26866: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-510: verdict=pending:closed wait=pending", + "511-587: verdict=ready-strong wait=ready@start", + "588-25926: verdict=pending:closed wait=pending", + "25927-26866: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@unknown.json new file mode 100644 index 00000000000..4564c04d132 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-composer@unknown.json @@ -0,0 +1,19 @@ +{ + "description": "omp recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-510: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "511: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "512-570: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "571-587: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "588-25926: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "25927-26866: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start" + ], + "clockless": [ + "0-510: verdict=pending:open wait=ready@poll", + "511-587: verdict=ready-strong wait=ready@start", + "588-25926: verdict=pending:closed wait=pending", + "25927-26866: verdict=ready-strong wait=ready@start" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@agent.json new file mode 100644 index 00000000000..ea6693bfa5e --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@agent.json @@ -0,0 +1,17 @@ +{ + "description": "omp recording at 120x40 replayed on the agent pane, one entry per chunk", + "observations": { + "clocked": [ + "0-909: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending", + "910: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "911-969: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "970-986: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "987-25161: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" + ], + "clockless": [ + "0-909: verdict=pending:closed wait=pending", + "910-986: verdict=ready-strong wait=ready@start", + "987-25161: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@unknown.json new file mode 100644 index 00000000000..484e8bd78f9 --- /dev/null +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-18-setup@unknown.json @@ -0,0 +1,17 @@ +{ + "description": "omp recording at 120x40 replayed on the unknown pane, one entry per chunk", + "observations": { + "clocked": [ + "0-909: now=pending:open edge=pending:open quiet=pending:open wait=ready@poll", + "910: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", + "911-969: now=pending:closed edge=ready-strong quiet=ready-strong wait=ready@start", + "970-986: now=ready-strong edge=ready-strong quiet=ready-strong wait=ready@start", + "987-25161: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" + ], + "clockless": [ + "0-909: verdict=pending:open wait=ready@poll", + "910-986: verdict=ready-strong wait=ready@start", + "987-25161: verdict=pending:closed wait=pending" + ] + } +} diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json index e2d95131a67..f0d51f39845 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@agent.json @@ -3,9 +3,13 @@ "observations": { "clocked": [ "0: now=working edge=working quiet=working wait=pending", - "1: now=pending:closed edge=pending:closed quiet=ready-weak wait=ready@start", + "1: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", "2-3: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" ], - "clockless": ["0: verdict=working wait=pending", "1-3: verdict=pending:closed wait=pending"] + "clockless": [ + "0: verdict=working wait=pending", + "1: verdict=ready-strong wait=ready@start", + "2-3: verdict=pending:closed wait=pending" + ] } } diff --git a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json index 5168edca93e..6d55bf91a4d 100644 --- a/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json +++ b/src/main/runtime/__fixtures__/readiness-census/transcript--omp-native-title-win32@unknown.json @@ -3,9 +3,13 @@ "observations": { "clocked": [ "0: now=working edge=working quiet=working wait=pending", - "1: now=pending:closed edge=pending:closed quiet=ready-weak wait=ready@start", + "1: now=pending:closed edge=pending:closed quiet=ready-strong wait=ready@start", "2-3: now=pending:closed edge=pending:closed quiet=pending:closed wait=pending" ], - "clockless": ["0: verdict=working wait=pending", "1-3: verdict=pending:closed wait=pending"] + "clockless": [ + "0: verdict=working wait=pending", + "1: verdict=ready-strong wait=ready@start", + "2-3: verdict=pending:closed wait=pending" + ] } } diff --git a/src/main/runtime/agent-state-rules/active-agent-state-rules.ts b/src/main/runtime/agent-state-rules/active-agent-state-rules.ts new file mode 100644 index 00000000000..bc6c0e1eff8 --- /dev/null +++ b/src/main/runtime/agent-state-rules/active-agent-state-rules.ts @@ -0,0 +1,55 @@ +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { BUNDLED_AGENT_STATE_RULES_VERSION } from './agent-state-rules-bundle' +import type { AgentStateRulesFile } from './agent-state-rules-schema' + +/** Where the rules in use came from: precedence is override, then downloaded, then bundled. */ +export type AgentStateRulesSource = 'bundled' | 'downloaded' | 'override' + +export type ActiveAgentStateRules = { + files: readonly AgentStateRulesFile[] + version: number + source: AgentStateRulesSource +} + +export const BUNDLED_AGENT_STATE_RULES: ActiveAgentStateRules = { + files: BUNDLED_AGENT_STATE_RULE_FILES, + version: BUNDLED_AGENT_STATE_RULES_VERSION, + source: 'bundled' +} + +let active = BUNDLED_AGENT_STATE_RULES + +/** Replaces the bundled files of the same id; an agent the bundle does not name keeps its own. */ +export function overlayOnBundledAgentStateRules( + files: readonly AgentStateRulesFile[] +): readonly AgentStateRulesFile[] { + const byId = new Map(files.map((file) => [file.id, file])) + const merged = BUNDLED_AGENT_STATE_RULES.files.map((file) => byId.get(file.id) ?? file) + const bundledIds = new Set(BUNDLED_AGENT_STATE_RULES.files.map((file) => file.id)) + return [...merged, ...files.filter((file) => !bundledIds.has(file.id))] +} + +export function getActiveAgentStateRules(): ActiveAgentStateRules { + return active +} + +/** Hot reload: every compiled view below recompiles from `next` on its next read. */ +export function activateAgentStateRules(next: ActiveAgentStateRules): void { + active = next +} + +/** + * A view compiled from the active files, rebuilt only when they change. Why keyed on the array: + * readers run on every poll, and one identity check is all a stable rule set costs them. + */ +export function compiledFromActiveAgentStateRules( + compile: (files: readonly AgentStateRulesFile[]) => T +): () => T { + let compiled: { files: readonly AgentStateRulesFile[]; value: T } | null = null + return () => { + if (compiled?.files !== active.files) { + compiled = { files: active.files, value: compile(active.files) } + } + return compiled.value + } +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts new file mode 100644 index 00000000000..679c6f7a3d4 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATE_RULES_BUNDLE_MAX_BYTES, + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS, + parseAgentStateRulesBundle +} from './agent-state-rules-bundle' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' + +function file(id: string): Record { + const found = BUNDLED_AGENT_STATE_RULE_FILES.find((candidate) => candidate.id === id) + return structuredClone({ ...found }) +} + +function bundle(overrides: Record = {}): string { + return JSON.stringify({ + version: 2, + engineVersion: 1, + files: [file('claude')], + ...overrides + }) +} + +function rejection(text: string, scope: 'live-updatable' | 'any-agent' = 'live-updatable') { + const result = parseAgentStateRulesBundle(text, scope) + return result.ok ? null : result.error +} + +describe('agent state rules bundle', () => { + it('accepts a live-updatable file and carries the bundledOnly flag', () => { + const result = parseAgentStateRulesBundle(bundle({ bundledOnly: true }), 'live-updatable') + expect(result).toMatchObject({ + ok: true, + bundle: { version: 2, bundledOnly: true } + }) + }) + + it.each([ + ['an unknown top-level field', bundle({ signature: 'x' }), 'Unrecognized key'], + ['a version that is not a positive integer', bundle({ version: '2026.10.02' }), 'version'], + ['version zero', bundle({ version: 0 }), 'version'], + ['a missing version', bundle({ version: undefined }), 'version'], + ['another engine', bundle({ engineVersion: 2 }), 'engineVersion'], + [ + 'a file with a misspelled field', + bundle({ files: [{ ...file('claude'), rule: [] }] }), + 'agent state rules file 0' + ], + ['two files for one agent', bundle({ files: [file('claude'), file('claude')] }), 'two files'], + ['an agent with no transcript suite', bundle({ files: [file('gemini')] }), 'gemini'] + ])('rejects %s', (_label, text, error) => { + expect(rejection(text)).toContain(error) + }) + + it('rejects text over the size cap before parsing it', () => { + expect(rejection(`${bundle()}${' '.repeat(AGENT_STATE_RULES_BUNDLE_MAX_BYTES)}`)).toBe( + `larger than ${AGENT_STATE_RULES_BUNDLE_MAX_BYTES} bytes` + ) + }) + + it('lets a local override carry any agent', () => { + expect(rejection(bundle({ files: [file('gemini')] }), 'any-agent')).toBeNull() + }) + + it('names only agents that have a bundled rule file', () => { + const bundled = new Set(BUNDLED_AGENT_STATE_RULE_FILES.map((rules) => rules.id)) + expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !bundled.has(id))).toEqual([]) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts new file mode 100644 index 00000000000..b4b77cd1ffb --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import release from './agent-state-rules-release.json' +import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' +import { + AGENT_STATE_RULES_ENGINE_VERSION, + type AgentStateRulesFile +} from './agent-state-rules-schema' + +/** + * The published bundle, `agent-state-rules.json`: the live-updatable agents' rule files, built by + * config/scripts/agent-state-rules-bundle.mjs from the per-agent files and + * agent-state-rules-release.json. `bundledOnly` tells apps to fall back to the rules they shipped. + */ + +// Why a cap: the file comes off the network and is parsed on the main thread. +export const AGENT_STATE_RULES_BUNDLE_MAX_BYTES = 256 * 1024 + +/** The version of the rules this build ships; a download must be higher to replace them. */ +export const BUNDLED_AGENT_STATE_RULES_VERSION: number = release.version + +/** The rule files a rules release may carry: those the publish gate can replay transcripts for. */ +export const LIVE_UPDATABLE_AGENT_STATE_RULE_IDS: ReadonlySet = new Set( + release.liveUpdatable +) + +// Why the engine is checked here, before the files: a newer engine's files fail the file schema +// for a reason that is not theirs. +const BundleEnvelopeSchema = z + .object({ + version: z.number().int().positive(), + engineVersion: z.literal(AGENT_STATE_RULES_ENGINE_VERSION), + bundledOnly: z.boolean().optional(), + files: z.array(z.unknown()) + }) + .strict() + +export type AgentStateRulesBundle = { + version: number + bundledOnly: boolean + files: readonly AgentStateRulesFile[] +} + +export type AgentStateRulesBundleParse = + | { ok: true; bundle: AgentStateRulesBundle } + | { ok: false; error: string } + +/** + * Validates a bundle whole: a file that fails any check is rejected, never partly applied. + * `any-agent` is for the local override, which the user chose, so it may carry any agent. + */ +export function parseAgentStateRulesBundle( + text: string, + scope: 'live-updatable' | 'any-agent' +): AgentStateRulesBundleParse { + if (Buffer.byteLength(text, 'utf8') > AGENT_STATE_RULES_BUNDLE_MAX_BYTES) { + return { ok: false, error: `larger than ${AGENT_STATE_RULES_BUNDLE_MAX_BYTES} bytes` } + } + try { + const { version, bundledOnly, files } = BundleEnvelopeSchema.parse(JSON.parse(text)) + const parsed = parseAgentStateRuleFiles(files) + const outside = parsed.find((file) => !LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id)) + if (scope === 'live-updatable' && outside) { + return { ok: false, error: `carries ${outside.id}, which has no transcript suite to gate it` } + } + return { ok: true, bundle: { version, bundledOnly: bundledOnly ?? false, files: parsed } } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts index c15c60cb9d2..157351f18c7 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.test.ts @@ -211,6 +211,8 @@ describe('strength and quiet through the tui-idle ranking', () => { readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, readAgentRuleVerdict: () => ruled, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, agent: 'cline', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts index 5ba0b853b60..0d0e8beae68 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -1,7 +1,7 @@ import type { AgentStatus } from '../../../shared/agent-detection' import type { TuiAgent } from '../../../shared/tui-agent' import { compileScreenCondition } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import { UNKNOWN_PANE_RULES_ID, type AgentStateRuleAnswer, @@ -105,20 +105,23 @@ type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean; hooks: HookAuthority } -const FILES_BY_KEY: ReadonlyMap = new Map( - BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [ - file.id, - { - rules: compileAgentRules(file), - readsTrustedScreen: file.profile?.screenSource === 'trusted', - hooks: file.profile?.hooks ?? 'identity-only' - } - ]) +const filesByKey = compiledFromActiveAgentStateRules( + (files): ReadonlyMap => + new Map( + files.map((file) => [ + file.id, + { + rules: compileAgentRules(file), + readsTrustedScreen: file.profile?.screenSource === 'trusted', + hooks: file.profile?.hooks ?? 'identity-only' + } + ]) + ) ) // Why the unknown-pane file for a null agent: an adopted pane can still run a known agent. function compiledFileFor(agent: TuiAgent | null | undefined): CompiledFile | undefined { - return FILES_BY_KEY.get(agent ?? UNKNOWN_PANE_RULES_ID) + return filesByKey().get(agent ?? UNKNOWN_PANE_RULES_ID) } /** @@ -130,9 +133,9 @@ export function readsTrustedScreen(agent: TuiAgent | null | undefined): boolean return compiledFileFor(agent)?.readsTrustedScreen ?? false } -/** Whether a fresh hook row for the agent's main turn decides readiness ahead of its rules. */ -export function hooksAreAuthoritative(agent: TuiAgent | null | undefined): boolean { - return compiledFileFor(agent)?.hooks === 'authoritative' +/** Which fresh hook rows for the agent's main turn decide readiness ahead of its rules. */ +export function hookAuthority(agent: TuiAgent | null | undefined): HookAuthority { + return compiledFileFor(agent)?.hooks ?? 'identity-only' } function someRule( diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts new file mode 100644 index 00000000000..8400f38d9fd --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts @@ -0,0 +1,406 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getTerminalTailSentinelMatches } from '../terminal-tail-sentinel-index' +import { + activateAgentStateRules, + BUNDLED_AGENT_STATE_RULES, + getActiveAgentStateRules +} from './active-agent-state-rules' +import { BUNDLED_AGENT_STATE_RULES_VERSION } from './agent-state-rules-bundle' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { idleTitleRequiresQuiet } from './agent-state-rules-engine' +import { + AgentStateRulesLiveUpdater, + agentStateRulesCacheFileName, + agentStateRulesChannelForAppVersion, + agentStateRulesDownloadUrl, + type AgentStateRulesLiveUpdateDeps +} from './agent-state-rules-live-update' +import { detectExplicitIdleStatusFromTitle } from '../terminal-wait-detection' +import { showsIdleTitleAnchor } from './agent-state-title-anchors' + +const NEWER = 9999 +const NEWEST = 10000 + +function bundledFile(id: string): Record { + const file = BUNDLED_AGENT_STATE_RULE_FILES.find((candidate) => candidate.id === id) + if (!file) { + throw new Error(`no bundled ${id}`) + } + return structuredClone(file) +} + +// A Claude file whose title rule settles without quiet: visible through idleTitleRequiresQuiet. +function claudeWithoutQuiet(): Record { + const claude = bundledFile('claude') + return { + ...claude, + rules: [ + { + id: 'idle_title', + why: 'test', + priority: 100, + when: { region: 'title', status: 'idle' }, + answer: { state: 'idle', strength: 'weak', requiresQuiet: false } + } + ] + } +} + +function bundleText( + version: number, + files: unknown[] = [claudeWithoutQuiet()], + extra: Record = {} +): string { + return JSON.stringify({ version, engineVersion: 1, ...extra, files }) +} + +type Harness = { + updater: AgentStateRulesLiveUpdater + userData: string + settings: { agentStateRulesPath?: string | null; agentStateRulesLiveUpdates?: boolean } + fetch: ReturnType + onActivated: ReturnType +} + +let userData: string +let warn: ReturnType + +function status(): { version: number; source: string } { + const { version, source } = getActiveAgentStateRules() + return { version, source } +} + +function warnings(): string[] { + return warn.mock.calls.map(([message]) => String(message)) +} + +function harness(overrides: Partial = {}): Harness { + const settings: Harness['settings'] = {} + const fetch = vi.fn(async () => new Response('Not Found', { status: 404 })) + const onActivated = vi.fn() + const updater = new AgentStateRulesLiveUpdater({ + userDataPath: userData, + appVersion: '1.4.0', + isPackaged: true, + fetch, + readSettings: () => settings, + onActivated, + ...overrides + }) + return { updater, userData, settings, fetch, onActivated } +} + +function serve(h: Harness, text: string): void { + h.fetch.mockImplementation(async () => new Response(text, { status: 200 })) +} + +function cachePath(channel: 'next' | 'stable' = 'stable'): string { + return join(userData, agentStateRulesCacheFileName(channel)) +} + +function writeCache(text: string, channel: 'next' | 'stable' = 'stable'): void { + writeFileSync(cachePath(channel), text) +} + +beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'agent-state-rules-live-update-')) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + activateAgentStateRules(BUNDLED_AGENT_STATE_RULES) + warn.mockRestore() + rmSync(userData, { recursive: true, force: true }) +}) + +describe('agent state rules channel and URL', () => { + it.each([ + ['1.4.0', 'stable'], + ['1.4.1-rc.2', 'next'], + ['1.4.1-rc.2.perf', 'next'], + ['not-a-version', null] + ])('maps app version %s to %s', (version, channel) => { + expect(agentStateRulesChannelForAppVersion(version)).toBe(channel) + }) + + it('fetches the fixed release-download URL for the engine and channel', () => { + expect(agentStateRulesDownloadUrl('next')).toBe( + 'https://github.com/stablyai/orca/releases/download/agent-state-rules-engine-1-next/agent-state-rules.json' + ) + }) +}) + +describe('agent state rules live updates', () => { + it('starts on the bundled rules and fetches the stable tag for a stable app', async () => { + const h = harness() + await h.updater.start() + expect(h.fetch).toHaveBeenCalledWith(agentStateRulesDownloadUrl('stable'), expect.anything()) + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + expect(warnings()).toEqual(['[agent-state-rules] download failed: HTTP 404']) + h.updater.stop() + }) + + it('accepts a newer download, writes it atomically, and hot-reloads the engine', async () => { + const h = harness({ appVersion: '1.4.1-rc.0' }) + expect(idleTitleRequiresQuiet('claude')).toBe(true) + const text = bundleText(NEWER) + serve(h, text) + await h.updater.start() + expect(h.fetch).toHaveBeenCalledWith(agentStateRulesDownloadUrl('next'), expect.anything()) + expect(status()).toEqual({ version: NEWER, source: 'downloaded' }) + expect(warnings()).toEqual([]) + expect(idleTitleRequiresQuiet('claude')).toBe(false) + expect(readFileSync(cachePath('next'), 'utf8')).toBe(text) + expect(readdirSync(userData)).toEqual([agentStateRulesCacheFileName('next')]) + expect(h.onActivated).toHaveBeenCalledWith({ version: NEWER, source: 'downloaded' }) + h.updater.stop() + }) + + it('keeps every agent the download does not carry on its bundled file', async () => { + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + const active = getActiveAgentStateRules() + expect(active.files.map((file) => file.id)).toEqual( + BUNDLED_AGENT_STATE_RULE_FILES.map((file) => file.id) + ) + expect(active.files.find((file) => file.id === 'gemini')).toBe( + BUNDLED_AGENT_STATE_RULE_FILES.find((file) => file.id === 'gemini') + ) + h.updater.stop() + }) + + it.each([ + ['not newer than the bundled copy', bundleText(BUNDLED_AGENT_STATE_RULES_VERSION), null], + [ + 'built for another engine', + JSON.stringify({ version: NEWER, engineVersion: 2, files: [] }), + 'download rejected: ' + ], + [ + 'carrying an agent with no transcript suite', + bundleText(NEWER, [bundledFile('gemini')]), + 'download rejected: carries gemini, which has no transcript suite to gate it' + ], + [ + 'an unsafe pattern', + bundleText(NEWER, [ + { + ...bundledFile('claude'), + anchors: [ + { + id: 'bad', + why: 'test', + when: { region: 'title', status: 'idle', match: { regex: '(a+)+$' } }, + answer: { state: 'idle' } + } + ] + } + ]), + 'repeats a group' + ], + ['not JSON', '{', 'download rejected: '], + [ + 'over the size cap', + ' '.repeat(256 * 1024 + 1), + 'download failed: Response body exceeds 262144 byte limit' + ] + ])('refuses a download %s and keeps the bundled rules', async (_label, text, error) => { + const h = harness() + serve(h, text) + await h.updater.start() + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + if (error === null) { + expect(warnings()).toEqual([]) + } else { + expect(warnings()).toEqual([expect.stringContaining(error)]) + } + expect(readdirSync(userData)).toEqual([]) + h.updater.stop() + }) + + it('keeps the last good copy through a 404, a network error and an older download', async () => { + const h = harness() + serve(h, bundleText(NEWEST)) + await h.updater.start() + h.fetch.mockImplementation(async () => new Response('Not Found', { status: 404 })) + await h.updater.refresh() + expect(status()).toMatchObject({ version: NEWEST, source: 'downloaded' }) + h.fetch.mockImplementation(async () => { + throw new Error('offline') + }) + await h.updater.refresh() + expect(status()).toEqual({ version: NEWEST, source: 'downloaded' }) + expect(warnings().at(-1)).toBe('[agent-state-rules] download failed: offline') + serve(h, bundleText(NEWER)) + await h.updater.refresh() + expect(status()).toEqual({ version: NEWEST, source: 'downloaded' }) + expect(JSON.parse(readFileSync(cachePath(), 'utf8'))).toMatchObject({ version: NEWEST }) + h.updater.stop() + }) + + it('activates a cached download newer than the bundled rules before any fetch lands', async () => { + writeCache(bundleText(NEWER)) + const h = harness() + h.fetch.mockImplementation(() => new Promise(() => {})) + void h.updater.start() + await vi.waitFor(() => expect(status().source).toBe('downloaded')) + expect(status().version).toBe(NEWER) + h.updater.stop() + }) + + it('ignores a cached download that is not newer than the bundled rules', async () => { + writeCache(bundleText(BUNDLED_AGENT_STATE_RULES_VERSION)) + const h = harness() + await h.updater.start() + expect(status().source).toBe('bundled') + h.updater.stop() + }) + + it("never reads the other channel's cache", async () => { + // Why: an RC build left next rules in the userData a stable build now shares. + writeCache(bundleText(NEWEST), 'next') + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + expect(status()).toMatchObject({ version: NEWER, source: 'downloaded' }) + expect(JSON.parse(readFileSync(cachePath('next'), 'utf8'))).toMatchObject({ version: NEWEST }) + h.updater.stop() + }) + + it('lets no superseded start or fetch change the rules after a restart', async () => { + const overridePath = join(userData, 'override.json') + writeFileSync(overridePath, bundleText(1, [bundledFile('gemini')])) + const h = harness() + let land: (response: Response) => void = () => {} + h.fetch.mockImplementation(() => new Promise((resolve) => (land = resolve))) + h.settings.agentStateRulesPath = overridePath + const superseded = h.updater.start() + await vi.waitFor(() => expect(h.fetch).toHaveBeenCalledTimes(1)) + h.settings.agentStateRulesPath = null + h.settings.agentStateRulesLiveUpdates = false + await h.updater.start() + land(new Response(bundleText(NEWER), { status: 200 })) + await superseded + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + expect(readdirSync(userData)).toEqual(['override.json']) + }) + + it('refuses a download no newer than the cached one', async () => { + writeCache(bundleText(NEWEST)) + const h = harness() + serve(h, bundleText(NEWER, [bundledFile('claude')])) + await h.updater.start() + expect(status()).toMatchObject({ version: NEWEST, source: 'downloaded' }) + expect(idleTitleRequiresQuiet('claude')).toBe(false) + h.updater.stop() + }) + + it('falls back to the bundled rules when a published bundle says bundledOnly', async () => { + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + serve(h, bundleText(NEWEST, [claudeWithoutQuiet()], { bundledOnly: true })) + await h.updater.refresh() + expect(status()).toMatchObject({ source: 'bundled' }) + expect(idleTitleRequiresQuiet('claude')).toBe(true) + h.updater.stop() + }) + + it('uses only the bundled rules when the setting turns live updates off', async () => { + writeCache(bundleText(NEWER)) + const h = harness() + h.settings.agentStateRulesLiveUpdates = false + await h.updater.start() + expect(h.fetch).not.toHaveBeenCalled() + expect(status().source).toBe('bundled') + h.updater.stop() + }) + + it('never fetches from an unpackaged build', async () => { + const h = harness({ isPackaged: false }) + await h.updater.start() + expect(h.fetch).not.toHaveBeenCalled() + h.updater.stop() + }) + + it('ranks a local override over a download over the bundled rules', async () => { + const overridePath = join(userData, 'override.json') + // Why gemini: the override may carry an agent a rules release cannot. + writeFileSync(overridePath, bundleText(1, [bundledFile('gemini')])) + writeCache(bundleText(NEWER)) + const h = harness() + h.settings.agentStateRulesPath = overridePath + await h.updater.start() + expect(status()).toMatchObject({ version: 1, source: 'override' }) + + h.settings.agentStateRulesPath = null + await h.updater.start() + expect(status()).toMatchObject({ version: NEWER, source: 'downloaded' }) + h.updater.stop() + }) + + it('reports a rejected override and keeps the next source in line', async () => { + const overridePath = join(userData, 'override.json') + writeFileSync(overridePath, '{"version":1,"engineVersion":1,"files":[{"id":"claude"}]}') + const h = harness() + h.settings.agentStateRulesPath = overridePath + await h.updater.start() + expect(status().source).toBe('bundled') + expect(warnings()[0]).toContain(`override ${overridePath} rejected`) + expect(h.onActivated).not.toHaveBeenCalled() + h.updater.stop() + }) + + it('still activates an accepted download when the cache cannot be written', async () => { + const h = harness({ userDataPath: join(userData, 'missing', 'dir') }) + serve(h, bundleText(NEWER)) + await h.updater.start() + expect(status().source).toBe('downloaded') + expect(warnings()).toEqual([expect.stringContaining('downloaded rules not cached')]) + h.updater.stop() + }) +}) + +describe('agent state rules hot reload', () => { + it('recompiles the title anchors every pane reads, past the title memo', () => { + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(true) + expect(detectExplicitIdleStatusFromTitle('✳ Claude Code')).toBe('idle') + activateAgentStateRules({ + ...BUNDLED_AGENT_STATE_RULES, + files: BUNDLED_AGENT_STATE_RULES.files.map((file) => + file.id === 'claude' ? { ...file, anchors: [] } : file + ) + }) + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(false) + expect(detectExplicitIdleStatusFromTitle('✳ Claude Code')).toBeNull() + }) + + it('rescans a tail the sentinel index already covered when a blocked anchor arrives', () => { + const lines = ['zebra crossing prompt'] + expect(getTerminalTailSentinelMatches(lines)).toEqual([]) + activateAgentStateRules({ + ...BUNDLED_AGENT_STATE_RULES, + files: BUNDLED_AGENT_STATE_RULES.files.map((file) => + file.id === 'claude' + ? { + ...file, + anchors: [ + ...file.anchors, + { + id: 'zebra', + why: 'test', + when: { region: 'text', find: { lastOf: 'zebra crossing' } }, + answer: { state: 'blocked', reason: 'agent-approval-prompt' } + } + ] + } + : file + ) + }) + expect(getTerminalTailSentinelMatches(lines)).toEqual([0]) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts new file mode 100644 index 00000000000..ee4e7440967 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts @@ -0,0 +1,259 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { getAppEnvironment } from '../../../shared/app-environment' +import { readFetchResponseTextWithinLimit } from '../../../shared/fetch-response-body' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getVersionChannel, MAIN_RELEASE_REPO } from '../../../shared/release-channel' +import { getMainHttpClient } from '../../network/http-client' +import { writePluginFileAtomically } from '../../plugins/plugin-atomic-file-write' +import { + activateAgentStateRules, + BUNDLED_AGENT_STATE_RULES, + getActiveAgentStateRules, + overlayOnBundledAgentStateRules, + type ActiveAgentStateRules, + type AgentStateRulesSource +} from './active-agent-state-rules' +import { + AGENT_STATE_RULES_BUNDLE_MAX_BYTES, + BUNDLED_AGENT_STATE_RULES_VERSION, + parseAgentStateRulesBundle, + type AgentStateRulesBundle +} from './agent-state-rules-bundle' +import { AGENT_STATE_RULES_ENGINE_VERSION } from './agent-state-rules-schema' + +const REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 30_000 + +export type AgentStateRulesChannel = 'next' | 'stable' + +/** Stable apps read the stable tag; RC, hourly, daily and adhoc builds soak the next one first. */ +export function agentStateRulesChannelForAppVersion( + appVersion: string +): AgentStateRulesChannel | null { + const channel = getVersionChannel(appVersion) + if (!channel) { + return null + } + return channel === 'stable' ? 'stable' : 'next' +} + +// Why a fixed release-download URL: no API call or rate limit, and no "latest" lookup to steer. +export function agentStateRulesDownloadUrl(channel: AgentStateRulesChannel): string { + const tag = `agent-state-rules-engine-${AGENT_STATE_RULES_ENGINE_VERSION}-${channel}` + return `https://github.com/${MAIN_RELEASE_REPO}/releases/download/${tag}/agent-state-rules.json` +} + +// Why per channel: stable and RC builds share userData, and a stable app must not run, or be +// held below, rules that only next has published. +export function agentStateRulesCacheFileName(channel: AgentStateRulesChannel): string { + return `agent-state-rules-${channel}.json` +} + +type LiveUpdateSettings = Pick + +export type AgentStateRulesLiveUpdateDeps = { + userDataPath: string + appVersion: string + /** Unpackaged dev and test runs never download. */ + isPackaged: boolean + fetch: (url: string, init?: RequestInit) => Promise + readSettings: () => LiveUpdateSettings + /** Called when the active version or source changes, for diagnostics and crash reports. */ + onActivated: (rules: { version: number; source: AgentStateRulesSource }) => void +} + +function warn(message: string): void { + console.warn(`[agent-state-rules] ${message}`) +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function parseOrWarn( + label: string, + text: string, + scope: 'live-updatable' | 'any-agent' +): AgentStateRulesBundle | null { + const parsed = parseAgentStateRulesBundle(text, scope) + if (!parsed.ok) { + warn(`${label} rejected: ${parsed.error}`) + return null + } + return parsed.bundle +} + +async function readBundleFile( + label: string, + path: string, + scope: 'live-updatable' | 'any-agent', + warnIfMissing: boolean +): Promise { + try { + return parseOrWarn(label, await readFile(path, 'utf8'), scope) + } catch (error) { + const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT' + if (!missing || warnIfMissing) { + warn(`${label} unreadable: ${describeError(error)}`) + } + return null + } +} + +/** + * Keeps the active agent state rules current: a local override, else a downloaded copy newer than + * the bundled one, else the bundled rules. Any failure keeps the last good copy active. + */ +export class AgentStateRulesLiveUpdater { + private override: AgentStateRulesBundle | null = null + /** Always newer than the bundled rules, so it is also the floor a download must clear. */ + private downloaded: AgentStateRulesBundle | null = null + // Why an identity per start: a settings change restarts while an earlier read or fetch may + // still be pending, and a superseded one must not change the active rules. + private run: { channel: AgentStateRulesChannel | null } | null = null + private timer: ReturnType | null = null + + constructor(private readonly deps: AgentStateRulesLiveUpdateDeps) {} + + /** Loads the override and the cached download, then fetches now and on an interval. Re-run it + * when the settings it reads change. */ + async start(): Promise { + this.stop() + const settings = this.deps.readSettings() + const live = this.deps.isPackaged && settings.agentStateRulesLiveUpdates !== false + const run = { channel: live ? agentStateRulesChannelForAppVersion(this.deps.appVersion) : null } + this.run = run + const overridePath = settings.agentStateRulesPath + const [override, cached] = await Promise.all([ + // Why any agent: the user chose this file, so the transcript gate on releases does not apply. + overridePath + ? readBundleFile(`override ${overridePath}`, overridePath, 'any-agent', true) + : null, + run.channel + ? readBundleFile('cached rules', this.cachePath(run.channel), 'live-updatable', false) + : null + ]) + if (this.run !== run) { + return + } + this.override = override + // Why re-check the version: an app update may have bundled rules newer than the cache. + this.downloaded = cached && cached.version > BUNDLED_AGENT_STATE_RULES_VERSION ? cached : null + this.activate() + if (run.channel) { + this.timer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS) + this.timer.unref?.() + await this.refresh() + } + } + + stop(): void { + this.run = null + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** Fetches the channel's file now; does nothing unless started with live updates on. */ + async refresh(): Promise { + const run = this.run + if (!run?.channel) { + return + } + const text = await this.fetchText(run.channel) + if (this.run !== run || text === null) { + return + } + const bundle = parseOrWarn('download', text, 'live-updatable') + // Why higher than both: an app whose bundled rules already hold a fix must not be shadowed by + // an older download, and a cached copy must never be replaced by an older one. + const floor = this.downloaded?.version ?? BUNDLED_AGENT_STATE_RULES_VERSION + if (!bundle || bundle.version <= floor) { + return + } + this.downloaded = bundle + this.activate() + try { + await writePluginFileAtomically(this.cachePath(run.channel), text) + } catch (error) { + warn(`downloaded rules not cached: ${describeError(error)}`) + } + } + + private cachePath(channel: AgentStateRulesChannel): string { + return join(this.deps.userDataPath, agentStateRulesCacheFileName(channel)) + } + + /** The published file's text, or null after warning why there is none. */ + private async fetchText(channel: AgentStateRulesChannel): Promise { + try { + const response = await this.deps.fetch(agentStateRulesDownloadUrl(channel), { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + // Why not fatal: a 404 is also what a re-upload in progress looks like. + warn(`download failed: HTTP ${response.status}`) + return null + } + return await readFetchResponseTextWithinLimit(response, AGENT_STATE_RULES_BUNDLE_MAX_BYTES) + } catch (error) { + warn(`download failed: ${describeError(error)}`) + return null + } + } + + private resolveActive(): ActiveAgentStateRules { + const chosen = this.override ?? (this.downloaded?.bundledOnly ? null : this.downloaded) + if (!chosen) { + return BUNDLED_AGENT_STATE_RULES + } + return { + files: overlayOnBundledAgentStateRules(chosen.files), + version: chosen.version, + source: chosen === this.override ? 'override' : 'downloaded' + } + } + + private activate(): void { + const previous = getActiveAgentStateRules() + const next = this.resolveActive() + activateAgentStateRules(next) + if (previous.version !== next.version || previous.source !== next.source) { + this.deps.onActivated({ version: next.version, source: next.source }) + } + } +} + +type SettingsStore = { + getSettings: () => LiveUpdateSettings + onSettingsChanged: (listener: (updates: Partial) => void) => unknown +} + +/** + * Starts live updates on this host: the desktop, `orca serve` and orcad each fetch their own copy, + * so a paired client never supplies the rules a host evaluates with. + */ +export function startAgentStateRulesLiveUpdates( + store: SettingsStore, + onActivated: AgentStateRulesLiveUpdateDeps['onActivated'] +): void { + const environment = getAppEnvironment() + const updater = new AgentStateRulesLiveUpdater({ + userDataPath: environment.getPath('userData'), + appVersion: environment.getVersion(), + isPackaged: environment.isPackaged(), + fetch: (url, init) => getMainHttpClient().fetch(url, init), + readSettings: () => store.getSettings(), + onActivated + }) + void updater.start() + store.onSettingsChanged((updates) => { + if ('agentStateRulesPath' in updates || 'agentStateRulesLiveUpdates' in updates) { + void updater.start() + } + }) + environment.onWillQuit(() => updater.stop()) +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-release.json b/src/main/runtime/agent-state-rules/agent-state-rules-release.json new file mode 100644 index 00000000000..6876a5a2170 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-release.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "liveUpdatable": [ + "antigravity", + "claude", + "cline", + "codex", + "cursor", + "omp", + "opencode", + "prime-agent", + "unknown-pane" + ] +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts index f5aafc2db3a..77faf6ad35e 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -11,7 +11,7 @@ import { findUnsafePatternReason } from './agent-state-rule-pattern-safety' * show) plus `answer`. Once a version ships, adding a region, predicate or answer bumps * `engineVersion`; until then version 1 is still being defined. */ -const AGENT_STATE_RULES_ENGINE_VERSION = 1 +export const AGENT_STATE_RULES_ENGINE_VERSION = 1 const MAX_PATTERN_LENGTH = 200 const MAX_RULES = 32 @@ -221,10 +221,12 @@ const AnchorSchema = z /** * How far readiness may trust the agent's hooks. `authoritative`: they report every way the main * agent's turn ends (done, cancelled, an approval granted or denied), so a fresh hook row decides - * ahead of the rules. `identity-only` (the default): some end sends no event (Claude's denied - * approval and Esc mid-tool), so hooks only name the agent and the rules decide. + * ahead of the rules. `turn-end`: a hook `done` is always a real turn end, but some ends may send + * nothing (Codex before its `Interrupt` hook), so only a `done` decides and a `working` or + * permission row leaves the rules to decide. `identity-only` (the default): hooks only name the + * agent and the rules decide (Claude sends no event when an approval is denied or Esc stops a tool). */ -const HOOK_AUTHORITIES = ['authoritative', 'identity-only'] as const +const HOOK_AUTHORITIES = ['authoritative', 'turn-end', 'identity-only'] as const /** Facts about the agent that are not detection rules. */ const ProfileSchema = z diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts index 97fda216dc6..2dbdea7c3b1 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' import { compileTextAnchors, findPromptAnchorIndexes } from './agent-state-text-anchors' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './blocked-text-layer' import { detectTerminalWaitBlockedReason } from '../terminal-wait-detection' function anchorsOf(anchors: unknown[]) { @@ -79,6 +79,6 @@ describe('the bundled Cursor approval menu', () => { describe('the blocked layer prefilter', () => { it('includes every bundled blocked anchor', () => { - expect(TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test('Run this command?')).toBe(true) + expect(terminalWaitBlockedSentinelRe().test('Run this command?')).toBe(true) }) }) diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts index 5de562de05a..4c59b5a39e5 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts @@ -1,7 +1,7 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-types' import { startOfLastLines } from '../terminal-wait-tail-window' import { compileTextTest, type TextMatcher } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import type { Anchor, AgentStateRulesFile, @@ -96,14 +96,16 @@ export function compileTextAnchors(files: readonly AgentStateRulesFile[]): { } } -const TEXT_ANCHORS = compileTextAnchors(BUNDLED_AGENT_STATE_RULE_FILES) +const textAnchors = compiledFromActiveAgentStateRules(compileTextAnchors) /** The literal every blocked anchor needs, for the blocked layer's one-pass prefilter. */ -export const BLOCKED_ANCHOR_LITERALS: readonly string[] = TEXT_ANCHORS.blockedLiterals +export function blockedAnchorLiterals(): readonly string[] { + return textAnchors().blockedLiterals +} /** Every rule file's blocked anchor found in the blocked layer's live window. */ export function findBlockedAnchorSignals(window: string): BlockedTextSignal[] { - return TEXT_ANCHORS.blocked.flatMap((find) => { + return textAnchors().blocked.flatMap((find) => { const hit = find(window) return hit?.answer.state === 'blocked' ? [{ reason: hit.answer.reason, index: hit.index }] : [] }) @@ -119,7 +121,7 @@ export function findPromptAnchorIndexes(normalized: string): { } { let live: number | null = null let ready: number | null = null - for (const find of TEXT_ANCHORS.prompts) { + for (const find of textAnchors().prompts) { const hit = find(normalized) if (hit === null) { continue @@ -134,10 +136,10 @@ export function findPromptAnchorIndexes(normalized: string): { /** Whether any rule file's hold anchor shows: an agent is up but not yet taking input. */ export function showsHoldAnchor(normalized: string): boolean { - return TEXT_ANCHORS.holds.some((find) => find(normalized) !== null) + return textAnchors().holds.some((find) => find(normalized) !== null) } export function showsScreenProbeBanner(text: string): boolean { const normalized = text.toLowerCase() - return TEXT_ANCHORS.screenProbeBanners.some((banner) => normalized.includes(banner)) + return textAnchors().screenProbeBanners.some((banner) => normalized.includes(banner)) } diff --git a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts index 67fc6063408..0f41f5e7d00 100644 --- a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts +++ b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts @@ -1,6 +1,6 @@ import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' import { compileTextTest } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import type { AgentStateRulesFile, NamedTitlePredicate, @@ -26,9 +26,9 @@ function compileTitleAnchors(files: readonly AgentStateRulesFile[]): TitleAnchor ) } -const TITLE_ANCHORS = compileTitleAnchors(BUNDLED_AGENT_STATE_RULE_FILES) +const titleAnchors = compiledFromActiveAgentStateRules(compileTitleAnchors) /** Whether any rule file's title anchor marks an idle-classified `title` as an agent's own rest title. */ export function showsIdleTitleAnchor(title: string): boolean { - return TITLE_ANCHORS.some((matches) => matches(title)) + return titleAnchors().some((matches) => matches(title)) } diff --git a/src/main/runtime/agent-state-rules/blocked-text-layer.ts b/src/main/runtime/agent-state-rules/blocked-text-layer.ts index 9e8d486bb35..9787f6966f9 100644 --- a/src/main/runtime/agent-state-rules/blocked-text-layer.ts +++ b/src/main/runtime/agent-state-rules/blocked-text-layer.ts @@ -1,8 +1,9 @@ import { escapeRegex } from '../../../shared/string-utils' import { findStartupDialogBlockedSignals } from '../startup-dialog-blocked-signals' import { startOfLastNonBlankLines } from '../terminal-wait-tail-window' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import { - BLOCKED_ANCHOR_LITERALS, + blockedAnchorLiterals, findBlockedAnchorSignals, showsHoldAnchor, type BlockedTextSignal @@ -17,10 +18,16 @@ import { const BUILT_IN_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always/ -/** Matches any line that may carry a blocker; a cheap negative test before the full scan. */ -export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = new RegExp( - [BUILT_IN_SENTINEL_RE.source, ...BLOCKED_ANCHOR_LITERALS.map(escapeRegex)].join('|'), - 'i' +/** + * Matches any line that may carry a blocker; a cheap negative test before the full scan. The same + * object until the active rules change, so a caller may key cached matches on it. + */ +export const terminalWaitBlockedSentinelRe = compiledFromActiveAgentStateRules( + () => + new RegExp( + [BUILT_IN_SENTINEL_RE.source, ...blockedAnchorLiterals().map(escapeRegex)].join('|'), + 'i' + ) ) // Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the @@ -32,7 +39,7 @@ export function findTerminalWaitBlockedSignal(fullTail: string): BlockedTextSign const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES) const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart) // Why: one combined negative scan avoids a dozen searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + if (!terminalWaitBlockedSentinelRe().test(normalized)) { return null } const signal = findBlockedSignalInLiveWindow(normalized) diff --git a/src/main/runtime/agent-state-rules/codex.json b/src/main/runtime/agent-state-rules/codex.json index b52d85a8d6f..db023c72af9 100644 --- a/src/main/runtime/agent-state-rules/codex.json +++ b/src/main/runtime/agent-state-rules/codex.json @@ -1,8 +1,23 @@ { "id": "codex", "engineVersion": 1, - "profile": { "screenSource": "live" }, + "profile": { "screenSource": "live", "hooks": "turn-end" }, "anchors": [ + { + "id": "plan_implement_menu", + "why": "A Plan-mode turn ends in this menu after Codex's Stop hook fired, and it owns the keyboard, so text sent to the pane picks a choice. Codex paints it by cell diff, which drops spaces from the text copy, and leaves it in the tail once answered, so the key row must end the tail.", + "when": { + "region": "text", + "find": { "lastOf": "implement this plan?" }, + "after": { + "all": [ + { "regex": "no,\\s*stay\\s*in\\s*plan\\s*mode" }, + { "regex": "enter\\s*select\\s*·\\s*esc\\s*back\\s*$" } + ] + } + }, + "answer": { "state": "blocked", "reason": "agent-interactive-prompt" } + }, { "id": "ready_header", "why": "The startup header with its model and directory labels is the stable ready text across versions; Codex prints permissions only in YOLO mode. Its last occurrence counts, so a header repainted after a dialog proves the dialog was answered.", diff --git a/src/main/runtime/agent-transcript-replay-test-harness.ts b/src/main/runtime/agent-transcript-replay-test-harness.ts index 35e7726bea9..35364709505 100644 --- a/src/main/runtime/agent-transcript-replay-test-harness.ts +++ b/src/main/runtime/agent-transcript-replay-test-harness.ts @@ -11,7 +11,7 @@ import { visibleNonBlankTerminalLines } from './terminal-tail-read' const DEFAULT_CHUNK_CHARS = 64 -/** `screenLines` are readLiveTerminalScreenLines' rows; `ruledScreenLines` readScreenRuledLines'. */ +/** `screenLines` are readLiveTerminalScreenLines' rows; `ruledScreenLines` readRuledScreen's. */ export type TranscriptReplayFrame = { screenLines: string[] ruledScreenLines: string[] diff --git a/src/main/runtime/codex-quiet-ready-screen.test.ts b/src/main/runtime/codex-quiet-ready-screen.test.ts index 8a6c6cf211e..db6077a723e 100644 --- a/src/main/runtime/codex-quiet-ready-screen.test.ts +++ b/src/main/runtime/codex-quiet-ready-screen.test.ts @@ -198,6 +198,8 @@ describe('Codex composer ready screen, frame by frame', () => { isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -295,6 +297,8 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { isQuietReadyScreenBody(waitText, 'codex', () => screenLines), agent: 'codex', readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -352,6 +356,8 @@ describe('a busy 0.150-0.157 pane whose header stays in the tail', () => { readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText, 'codex', () => header), agent: 'codex', readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS }) @@ -381,6 +387,8 @@ describe('reading the live screen never removes quiet-lane readiness', () => { isKnownReadyPromptBody(frame.waitText, agent, () => frame.screenLines, true), agent, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS } satisfies Omit diff --git a/src/main/runtime/file-watcher-host.test.ts b/src/main/runtime/file-watcher-host.test.ts index 72608084a9a..f924d86db04 100644 --- a/src/main/runtime/file-watcher-host.test.ts +++ b/src/main/runtime/file-watcher-host.test.ts @@ -176,7 +176,13 @@ describe('watchFileExplorerInWatcherProcess', () => { expect(onEvents).toHaveBeenCalledWith([{ kind: 'overflow', absolutePath: '/repo' }]) }) - it('keeps same-root subscribers alive while a failed shard is replaced', async () => { + it.each([ + ['native watcher failure', new Error('native subscription stopped')], + [ + 'watcher process failure', + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ] + ])('keeps same-root subscribers alive after a %s', async (_kind, error) => { const watch = installSuccessfulWatch() const replacement = installSuccessfulWatch() const firstEvents = vi.fn<(events: FsChangeEvent[]) => void>() @@ -186,9 +192,7 @@ describe('watchFileExplorerInWatcherProcess', () => { await watchFileExplorerInWatcherProcess('/repo', firstEvents, firstError) await watchFileExplorerInWatcherProcess('/repo', secondEvents, secondError) - watch.hooks.onTerminalError?.( - new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') - ) + watch.hooks.onTerminalError?.(error) await vi.waitFor(() => expect(subscribeViaRuntimeWatcherProcessMock).toHaveBeenCalledTimes(2)) watch.callback(null, [{ type: 'update', path: '/repo/late.txt' }]) replacement.callback(null, [{ type: 'update', path: '/repo/recovered.txt' }]) diff --git a/src/main/runtime/managed-worktree-create-telemetry.test.ts b/src/main/runtime/managed-worktree-create-telemetry.test.ts new file mode 100644 index 00000000000..d790a317ea7 --- /dev/null +++ b/src/main/runtime/managed-worktree-create-telemetry.test.ts @@ -0,0 +1,335 @@ +// Runtime creates (CLI, agents, phone, paired clients, orchestration) reuse prepared checkouts +// like the app's own creates, so they must send the same create events, exactly once each. +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp'), isPackaged: false } +})) + +const mocks = vi.hoisted(() => ({ + track: vi.fn<(name: string, props: Record) => void>(), + probe: vi.fn(), + createLocal: vi.fn(), + createFolder: vi.fn(), + startTerminals: vi.fn() +})) + +vi.mock('../telemetry/client', () => ({ track: mocks.track, isTelemetryEnabled: () => true })) +vi.mock('../git/create-event-repo-probe', () => ({ probeCreateEventRepoFacts: mocks.probe })) +vi.mock('./runtime-local-worktree-create', () => ({ + createRuntimeLocalManagedWorktree: mocks.createLocal +})) +vi.mock('./runtime-folder-worktree-create', () => ({ + createRuntimeFolderWorktree: mocks.createFolder +})) +vi.mock('./runtime-local-worktree-terminal-startup', () => ({ + startRuntimeLocalWorktreeTerminals: mocks.startTerminals +})) +vi.mock('./runtime-local-worktree-setup', () => ({ + prepareRuntimeLocalWorktreeSetup: vi.fn(async () => ({ + effectiveDecision: 'skip', + hookFound: false, + shouldRunSetup: false, + didStartInProcessSetupHook: false + })) +})) +vi.mock('../ipc/filesystem-auth', () => ({ + invalidateAuthorizedRootsCache: vi.fn(), + invalidateAuthorizedRootsCacheForRepo: vi.fn() +})) + +import { OrcaRuntimeService } from './orca-runtime' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' +import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + _resetWorktreeCreateConcurrencyForTests, + beginPreparationWork, + type PreparationWork +} from '../worktree-create-concurrency' + +const gitRepo = { + id: 'repo-1', + path: '/repo', + displayName: 'Repo', + badgeColor: 'blue', + kind: 'git' +} +const worktree = { id: 'wt-1', path: '/worktrees/app', branch: 'app', repoId: gitRepo.id } + +type RuntimeInternals = { + resolveRepoSelector: (selector: string) => Promise + resolveLineageForWorktreeCreate: (input: unknown) => Promise + recordCreatedWorktreeLineage: (created: unknown, resolution: unknown) => unknown + getLocalGitExecutionOptionArgs: (repo: unknown) => unknown[] + getHostedReviewExecutionOptions: (repo: unknown) => unknown + invalidateResolvedWorktreeCache: () => void + invalidateWorktreeScanCacheForRepo: (repoId: string) => void + notifyWorktreesChanged: (repoId: string) => void + emitWorktreeLifecycle: (event: unknown) => void +} + +function makeRuntime(repo: Record = gitRepo): OrcaRuntimeService { + const store = { + getSettings: () => ({ disabledTuiAgents: ['codex'], workspaceDir: '/worktrees' }), + getProjectHostSetups: () => [] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every store method this create path reaches is supplied above. + const runtime = new OrcaRuntimeService(store as never) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the named members all exist on the service; the cast only exposes non-public ones to the spies. + const internals = runtime as unknown as RuntimeInternals + vi.spyOn(internals, 'resolveRepoSelector').mockResolvedValue(repo) + vi.spyOn(internals, 'resolveLineageForWorktreeCreate').mockResolvedValue(null) + vi.spyOn(internals, 'recordCreatedWorktreeLineage').mockReturnValue({ + lineage: null, + workspaceLineage: null, + warnings: [] + }) + vi.spyOn(internals, 'getLocalGitExecutionOptionArgs').mockReturnValue([{}]) + vi.spyOn(internals, 'getHostedReviewExecutionOptions').mockReturnValue(undefined) + vi.spyOn(internals, 'invalidateResolvedWorktreeCache').mockReturnValue(undefined) + vi.spyOn(internals, 'invalidateWorktreeScanCacheForRepo').mockReturnValue(undefined) + vi.spyOn(internals, 'notifyWorktreesChanged').mockReturnValue(undefined) + vi.spyOn(internals, 'emitWorktreeLifecycle').mockReturnValue(undefined) + return runtime +} + +type LocalCreateArgs = { timing: WorktreeCreateTimingRecorder; rearm: PreparationRearmHolder } + +function events(name: string): Record[] { + return mocks.track.mock.calls.filter(([event]) => event === name).map(([, props]) => props) +} + +function localCreateResult() { + return { + worktree, + worktreePath: worktree.path, + created: { path: worktree.path, head: 'abc', branch: 'app' }, + addResult: {}, + metadataResult: { lineage: null, workspaceLineage: null, warnings: [] } + } +} + +describe('runtime create events', () => { + beforeEach(() => { + mocks.track.mockReset() + mocks.probe.mockReset().mockResolvedValue({ postCheckoutHook: 'absent', indexEntryCount: 2048 }) + mocks.createFolder.mockReset().mockResolvedValue({ worktree: { id: 'folder-1' } }) + mocks.startTerminals.mockReset().mockResolvedValue({}) + mocks.createLocal.mockReset().mockImplementation(async ({ timing }: LocalCreateArgs) => { + timing.recordExecutionHost('local') + await timing.time('git_worktree_add', async () => { + timing.recordPreparedCheckout({ + status: 'hit', + reset: 'none', + origin: 'prefetch', + buildMs: 9_000, + idleMs: 120 + }) + }) + return localCreateResult() + }) + }) + + afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + }) + + it('sends one workspace_created with the runtime entry point and the timing fields', async () => { + await makeRuntime().createManagedWorktree({ + repoSelector: 'repo-1', + name: 'app', + baseBranch: 'origin/main', + telemetrySource: 'sidebar' + }) + await vi.waitFor(() => expect(events('workspace_created')).toHaveLength(1)) + + expect(events('workspace_created')[0]).toMatchObject({ + source: 'sidebar', + from_existing_branch: true, + create_entry_point: 'runtime', + execution_host: 'local', + prepared_checkout: 'hit', + prepared_checkout_reset: 'none', + prepared_checkout_origin: 'prefetch', + concurrent_creates: 0, + concurrent_preparations: 0, + repo_file_count_bucket: '1k-10k', + post_checkout_hook: 'absent' + }) + expect(typeof events('workspace_created')[0].git_worktree_add_ms).toBe('number') + expect(mocks.probe).toHaveBeenCalledWith('/repo') + expect(events('workspace_create_failed')).toHaveLength(0) + }) + + it('sends one workspace_create_failed naming the phase the create died in', async () => { + mocks.createLocal.mockImplementation(async ({ timing }: LocalCreateArgs) => + timing.time('git_worktree_add', async () => { + throw new Error('fatal: could not create work tree dir /worktrees/app') + }) + ) + + await expect( + makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + ).rejects.toThrow('could not create work tree') + + expect(events('workspace_create_failed')).toEqual([ + expect.objectContaining({ + source: 'unknown', + failed_phase: 'git_worktree_add', + create_entry_point: 'runtime' + }) + ]) + expect(JSON.stringify(events('workspace_create_failed'))).not.toMatch(/worktrees|fatal/) + expect(events('workspace_created')).toHaveLength(0) + }) + + it('counts preparation work running during the create but not the re-arm the create starts', async () => { + let competing: PreparationWork | undefined + mocks.createLocal.mockImplementation(async ({ rearm }: LocalCreateArgs) => { + competing = beginPreparationWork() + // The re-arm starts its build synchronously when fired, after the create's last step. + rearm.fire = () => { + beginPreparationWork() + beginPreparationWork() + } + return localCreateResult() + }) + + await makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + competing?.end() + await vi.waitFor(() => expect(events('workspace_created')).toHaveLength(1)) + + expect(events('workspace_created')[0]).toMatchObject({ concurrent_preparations: 1 }) + }) + + it('still returns the create when sending its event throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.track.mockImplementation(() => { + throw new Error('telemetry broke') + }) + + await expect( + makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + ).resolves.toMatchObject({ worktree: { id: 'wt-1' } }) + await vi.waitFor(() => expect(mocks.track).toHaveBeenCalledOnce()) + }) + + it('still rejects with the create error when sending the failure event throws', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + mocks.track.mockImplementation(() => { + throw new Error('telemetry broke') + }) + mocks.createLocal.mockRejectedValue(new Error('fatal: could not create work tree')) + + await expect( + makeRuntime().createManagedWorktree({ repoSelector: 'repo-1', name: 'app' }) + ).rejects.toThrow('could not create work tree') + expect(mocks.track).toHaveBeenCalledOnce() + }) + + it('carries the SSH create timing the runtime hands to the remote create', async () => { + const runtime = makeRuntime({ + id: 'repo-remote', + path: '/srv/app', + kind: 'git', + executionHostId: 'ssh:remote-1' + }) + const remoteCreate = vi.fn( + async (_repo: unknown, args: { timing?: WorktreeCreateTimingRecorder }) => { + // Stands in for createRemoteWorktree, which records into the recorder it is given. + args.timing?.recordExecutionHost('ssh') + await args.timing?.time('git_worktree_add', async () => {}) + return { worktree } + } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createManagedRemoteWorktree exists on the service; the cast only exposes the protected member to the spy. + const internals = runtime as unknown as { createManagedRemoteWorktree: typeof remoteCreate } + vi.spyOn(internals, 'createManagedRemoteWorktree').mockImplementation(remoteCreate) + + await runtime.createManagedWorktree({ repoSelector: 'repo-remote', name: 'app' }) + await vi.waitFor(() => expect(events('workspace_created')).toHaveLength(1)) + + expect(remoteCreate.mock.calls[0]?.[1].timing).toBeDefined() + expect(events('workspace_created')[0]).toMatchObject({ + create_entry_point: 'runtime', + execution_host: 'ssh' + }) + expect(typeof events('workspace_created')[0].git_worktree_add_ms).toBe('number') + // SSH would need a remote round trip, so the repo is not probed. + expect(mocks.probe).not.toHaveBeenCalled() + }) + + it('sends nothing for a folder workspace, as before', async () => { + await makeRuntime({ id: 'folder', path: '/notes', kind: 'folder' }).createManagedWorktree({ + repoSelector: 'folder', + name: 'notes' + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(mocks.track).not.toHaveBeenCalled() + }) + + it('sends nothing when the request is rejected before any create starts', async () => { + await expect( + makeRuntime().createManagedWorktree({ + repoSelector: 'repo-1', + name: 'app', + startupAgent: 'codex' + }) + ).rejects.toThrow('Selected agent is disabled') + expect(mocks.createLocal).not.toHaveBeenCalled() + expect(mocks.track).not.toHaveBeenCalled() + }) +}) + +function sourceFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + return sourceFiles(full) + } + return entry.name.endsWith('.ts') && !/\.(test|spec)\.ts$/.test(entry.name) ? [full] : [] + }) +} + +describe('one event per create', () => { + const mainDir = path.resolve(__dirname, '..') + const relative = (file: string): string => path.relative(mainDir, file).split(path.sep).join('/') + + it('sends the create events only from the shared sender, besides the SSH root adopt', () => { + const senders = sourceFiles(mainDir) + .filter((file) => + /track\(\s*'workspace_(created|create_failed)'/.test(readFileSync(file, 'utf8')) + ) + .map(relative) + .sort() + expect(senders).toEqual([ + 'ipc/worktrees/create/register-worktree-create-handlers.ts', + 'workspace-create-telemetry.ts' + ]) + const handler = readFileSync( + path.join(mainDir, 'ipc/worktrees/create/register-worktree-create-handlers.ts'), + 'utf8' + ) + // The remaining direct sends there belong to the root adopt, which no other entry point runs. + expect(handler.match(/track\(\s*'workspace_(created|create_failed)'/g)).toHaveLength(2) + }) + + it('keeps the app and runtime entry points from running each other', () => { + const handler = readFileSync( + path.join(mainDir, 'ipc/worktrees/create/register-worktree-create-handlers.ts'), + 'utf8' + ) + expect(handler).not.toMatch(/createManagedWorktree/) + const runtimeImportsHandler = sourceFiles(path.join(mainDir, 'runtime')).filter((file) => + /register-worktree-create-handlers|ipcMain\.emit|['"]worktrees:create['"]/.test( + readFileSync(file, 'utf8') + ) + ) + expect(runtimeImportsHandler.map(relative)).toEqual([]) + }) +}) diff --git a/src/main/runtime/omp-readiness-transcripts.test.ts b/src/main/runtime/omp-readiness-transcripts.test.ts new file mode 100644 index 00000000000..ecfd65e7cc3 --- /dev/null +++ b/src/main/runtime/omp-readiness-transcripts.test.ts @@ -0,0 +1,231 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { extractLastOscTitle } from '../../shared/osc-title-extraction' +import type { TuiAgent } from '../../shared/tui-agent' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +function transcript(name: string): string { + return readFileSync(join(__dirname, '__fixtures__', `${name}.txt`), 'utf8') +} + +// Synthetic repaint of the composer's bottom row (row 21 of the 120x40 capture): no capture +// exists of a post-turn, resumed or non-default-glyph composer, whose effort hint is gone or +// differs. +const HINTLESS_COMPOSER_ROW = '\x1b[21;1H\x1b[2K╰─' +const ASCII_HINT_COMPOSER_ROW = `\x1b[21;1H\x1b[2K╰─${' '.repeat(82)}Shift+Tab to change thinking effort` + +async function waitsReady( + options: { + name: string + title: string + launchAgent?: TuiAgent + repaint?: string + size?: { cols: number; rows: number } | null + busyFirst?: boolean + keepalive?: boolean + }, + timeoutMs = 5_000 +): Promise { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: options.launchAgent ? 'omp' : 'bun', + data: transcript(options.name), + ...(options.launchAgent ? { launchAgent: options.launchAgent } : {}), + ...(options.size === null ? {} : { size: options.size ?? { cols: 120, rows: 40 } }) + }) + if (options.busyFirst) { + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + } + if (options.repaint) { + // Why the wait: the grid ingests writes asynchronously, and the repaint precedes the title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, options.repaint, Date.now()) + await vi.advanceTimersByTimeAsync(50) + } + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${options.title}\x07`, Date.now()) + // OMP 18.4.5 re-asserts bracketed paste every second once a terminal answers its DECRQM probe, + // as xterm and main's query authority do; the capture tool answered nothing. + const keepalive = options.keepalive + ? setInterval(() => runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[?2004h', Date.now()), 1_000) + : null + const settled = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs }).then( + (result) => result.satisfied === true, + () => false + ) + await vi.advanceTimersByTimeAsync(timeoutMs) + if (keepalive) { + clearInterval(keepalive) + } + return settled +} + +describe('OMP 18.4.5 captured readiness', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it.each([ + ['omp-18-setup', 120, 40, false], + ['omp-18-composer', 120, 40, true], + ['omp-18-composer-narrow', 60, 24, true] + ] as const)('%s at %sx%s has readiness %s', async (name, cols, rows, ready) => { + const data = transcript(name) + expect(data).toContain('\x1b[') + expect(data).toContain('\r') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: extractLastOscTitle(data) ?? 'OMP', + foregroundProcess: 'omp', + data, + launchAgent: 'omp', + size: { cols, rows } + }) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = ready + ? expect(result).resolves.toMatchObject({ satisfied: true }) + : expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it('does not accept the composer while its native title still says working', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'OMP', + foregroundProcess: 'omp', + data: transcript('omp-18-composer'), + launchAgent: 'omp', + size: { cols: 120, rows: 40 } + }) + // Synthetic busy transition isolates the timer while preserving the captured composer grid. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π : capture-cwd\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b[0m', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const idle = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const idleAssertion = expect(idle).resolves.toMatchObject({ satisfied: true }) + await Promise.all([idleAssertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it.each([ + ['hintless', HINTLESS_COMPOSER_ROW], + ['ascii-glyph', ASCII_HINT_COMPOSER_ROW] + ])('accepts a %s composer: the effort hint retires and its glyphs vary', async (_, repaint) => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + repaint + }) + ).toBe(true) + }) + + it("accepts the titlebar extension's post-turn title on a hintless composer", async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: HINTLESS_COMPOSER_ROW, + busyFirst: true + }) + ).toBe(true) + }) + + it('accepts an OMP 17-style `π:` title through the quiet name-only lane', async () => { + expect( + await waitsReady({ name: 'omp-18-composer', title: 'π: capture-cwd', launchAgent: 'omp' }) + ).toBe(true) + }) + + it("refuses the wizard's splash, which has no step heading yet", async () => { + const setup = transcript('omp-18-setup') + const splash = setup.slice(0, setup.indexOf('Setup step')) + expect(splash).toContain('\x1b[?1049h') + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'omp', + data: splash, + launchAgent: 'omp', + size: { cols: 120, rows: 40 } + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;π > capture-cwd\x07', Date.now()) + const result = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 5_000 }) + const assertion = expect(result).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(5_000)]) + }) + + it.each([ + ['omp-18-composer', true], + ['omp-18-setup', false] + ] as const)('%s under the bracketed-paste keepalive has readiness %s', async (name, ready) => { + expect( + await waitsReady( + { name, title: 'π > capture-cwd', launchAgent: 'omp', keepalive: true }, + 10_000 + ) + ).toBe(ready) + }) + + it('accepts a post-turn answer that mentions a setup step on the normal screen', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + repaint: '\x1b[12;1H\x1b[2K Setup step 2 of 4: install the dependencies', + busyFirst: true + }) + ).toBe(true) + }) + + it.each(['π - capture-cwd', 'π: capture-cwd'])( + 'refuses %s while the setup wizard is on screen', + async (title) => { + expect(await waitsReady({ name: 'omp-18-setup', title, launchAgent: 'omp' })).toBe(false) + } + ) + + it('refuses `π >` with no trustworthy screen to rule the setup wizard out', async () => { + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π > capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(false) + // A post-turn title cannot come from setup, so it needs no screen. + expect( + await waitsReady({ + name: 'omp-18-composer', + title: 'π - capture-cwd', + launchAgent: 'omp', + size: null + }) + ).toBe(true) + }) + + it.each([['pi' as const], [undefined]])( + 'does not make the setup wizard ready at once for agent identity %s', + async (launchAgent) => { + // Identities other than omp keep main's quiet name-only lane, which cannot settle this soon. + expect( + await waitsReady( + { + name: 'omp-18-setup', + title: 'π > capture-cwd', + ...(launchAgent ? { launchAgent } : {}) + }, + 1_000 + ) + ).toBe(false) + } + ) +}) diff --git a/src/main/runtime/omp-terminal-readiness.ts b/src/main/runtime/omp-terminal-readiness.ts new file mode 100644 index 00000000000..de27a5bf59e --- /dev/null +++ b/src/main/runtime/omp-terminal-readiness.ts @@ -0,0 +1,17 @@ +import { getPiStateTitleStatus } from '../../shared/pi-state-title-marker' +import type { RuledScreen } from './screen-input-veto' + +/** + * OMP 18.4 runs its first-run setup wizard (splash, steps, outro) as a fullscreen overlay on the + * alternate screen, and keys typed there drive the wizard, not the composer. Every other OMP + * alternate-screen user is an overlay that owns input too. Why not the "Setup step N of M" + * heading: it only ever appears there, and on the normal screen it is an answer's own text. + */ +export function isOmpOverlayScreen(screen: RuledScreen): boolean { + return screen.alternateScreen +} + +/** OMP's own idle state title (`π > cwd`), which it already paints before the setup wizard. */ +export function isOmpIdleStateTitle(title: string | null | undefined): boolean { + return title !== null && title !== undefined && getPiStateTitleStatus(title) === 'idle' +} diff --git a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts index 3bc5b570704..57158256605 100644 --- a/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts +++ b/src/main/runtime/orca-runtime-adopt-terminal-orphans-from-inventory.ts @@ -18,6 +18,7 @@ import { resolveTerminalSessionWorktreeId } from './runtime-worktree-path-identi import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options' import { adoptRuntimeTerminalOrphansFromInventory } from './runtime-terminal-orphan-adoption' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' +import { getLatestPtyTitle } from './runtime-worktree-status-projection' import type { PtyLivenessVerdict } from '../../shared/pty-liveness-verdict' export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntimeWithSubscribeToTerminalResize { @@ -62,6 +63,7 @@ export class OrcaRuntimeWithAdoptTerminalOrphansFromInventory extends OrcaRuntim getPty: (handle) => this.getLivePtyForHandle(handle)?.pty ?? null, getLeaves: (ptyId) => this.getLeavesForPty(ptyId), getLeaf: (tabId, leafId) => this.leaves.get(this.getLeafKey(tabId, leafId)), + getDisplayTitle: (pty) => getLatestPtyTitle(this.getPtyDisplayRecord(pty)), replayPersistedSurface: (pty, tabId, paneKey) => recordPtySurface(pty, tabId, paneKey, SURFACE_CLAIM_WITHOUT_STANDING), recordAdoptedSurface: (pty, tabId, paneKey) => diff --git a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts index 2976c4dff9b..a0df20716ce 100644 --- a/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts +++ b/src/main/runtime/orca-runtime-apply-tracked-pty-title.ts @@ -6,13 +6,16 @@ import { terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-statu export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnpersistedTrackedTitleForPty { /** Apply one observed OSC title (raw form) to the PTY and leaf records. - * Returns true when the PTY record's title or status changed. */ + * Returns true when what the PTY shows changed: its record, or its display-only clear. */ protected applyTrackedPtyTitle( ptyId: string, rawTitle: string, normalizedTitle: string, meta?: TerminalTitleFactMeta ): boolean { + if (meta?.staleWorkingTitleClear) { + return this.applyStaleWorkingTitleClear(ptyId, rawTitle, normalizedTitle) + } // Why: status is detected from the RAW title (mirrors the renderer tracker), // so working/idle transitions are unaffected by normalization; the records // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to @@ -25,10 +28,12 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const recordedTitle = identityOnlyTitle ? null : normalizedTitle const agentStatus = identityOnlyTitle ? null : detectAgentStatusFromTitle(rawTitle) + // Why before retiring the clear: the lifecycle counts a turn from the status the clear showed. this.recordAgentPromptLifecycleState(ptyId, agentStatus) let ptyRecordChanged = false const pty = this.ptysById.get(ptyId) if (pty) { + pty.titleDisplayClear = null const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle const observedAt = this.nextTitleObservationSequence() @@ -152,6 +157,41 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper return ptyRecordChanged } + /** + * The stale-working timer's clear: it only guesses that the agent may have exited behind a + * working title. Of what a genuine title does, it skips the title/status evidence that + * readiness and delivery read (records, waiters, mailbox, delivery), and still re-derives the + * process evidence that guess is about: the foreground agent and the exit/completion check. + * Display takes the cleared title from `titleDisplayClear`; the caller publishes it. + */ + private applyStaleWorkingTitleClear( + ptyId: string, + rawTitle: string, + normalizedTitle: string + ): boolean { + const pty = this.ptysById.get(ptyId) + if (!pty) { + return false + } + const nativeStatus = pty.lastAgentStatus + const clearedStatus = detectAgentStatusFromTitle(rawTitle) + const observedAt = this.nextTitleObservationSequence() + const previousTitle = pty.titleDisplayClear?.title ?? pty.lastOscTitle + pty.titleDisplayClear = { title: normalizedTitle, observedAt, observedAtEpochMs: Date.now() } + if (nativeStatus === 'working' && clearedStatus === null) { + this.confirmPtyAgentExit(ptyId, true) + } + if (nativeStatus === clearedStatus) { + return previousTitle !== normalizedTitle + } + const foregroundRefresh = this.ptyForegroundAgent.refresh(ptyId, observedAt) + if (this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle)) { + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) + return false + } + return true + } + /** Cancel the per-PTY title tracker (stale-title timer included) on PTY * teardown so it cannot fire into pruned records. */ protected disposePtyTitleTracker(ptyId: string): void { @@ -179,6 +219,7 @@ export class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnper pty.lastOscTitle = null pty.lastOscTitleAt = null pty.lastOscTitleEpochMs = null + pty.titleDisplayClear = null pty.lastAgentStatus = null // Why: the prior process's first-party status would otherwise veto idle for its // replacement — a stale `working` keeps tui-idle unresolved on the new generation. diff --git a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts index 2211e2fbecd..5158625a7a2 100644 --- a/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts +++ b/src/main/runtime/orca-runtime-build-pty-terminal-summary.ts @@ -25,7 +25,7 @@ export class OrcaRuntimeWithBuildPtyTerminalSummary extends OrcaRuntimeWithGetPt ): RuntimeTerminalSummary { const worktree = worktreesById.get(pty.worktreeId) - const title = getLatestPtyTitle(pty) + const title = getLatestPtyTitle(this.getPtyDisplayRecord(pty)) const pane = parsePaneKey(pty.paneKey ?? '') const orphaned = !ptyHoldsRecordedSurface(pty, this.ptySurfaceTopology()) // A live process awaiting its pane binding is not evidence of an orphan. diff --git a/src/main/runtime/orca-runtime-create-managed-worktree.ts b/src/main/runtime/orca-runtime-create-managed-worktree.ts index ce2e4d1bc2f..63e3a010772 100644 --- a/src/main/runtime/orca-runtime-create-managed-worktree.ts +++ b/src/main/runtime/orca-runtime-create-managed-worktree.ts @@ -12,6 +12,8 @@ import type { PreparationRearmHolder } from '../worktree-create-preparation' import { prepareRuntimeLocalWorktreeSetup } from './runtime-local-worktree-setup' import { invalidateAuthorizedRootsCacheForRepo } from '../ipc/filesystem-auth' import { startRuntimeLocalWorktreeTerminals } from './runtime-local-worktree-terminal-startup' +import { trackRuntimeWorkspaceCreate } from '../workspace-create-telemetry' +import type { RuntimeWorkspaceCreateEvents } from '../workspace-create-telemetry' export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWorktreeTerminalProvisioningHost { async createManagedWorktree( @@ -22,7 +24,9 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork // still arm the replacement. On success it fires last, once the startup terminals are up. const rearm: PreparationRearmHolder = { fire: () => {} } try { - return await this.performManagedWorktreeCreate(args, rearm) + return await trackRuntimeWorkspaceCreate(args, (events) => + this.performManagedWorktreeCreate(args, rearm, events) + ) } finally { rearm.fire() } @@ -30,7 +34,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork private async performManagedWorktreeCreate( args: RuntimeManagedWorktreeCreateArgs, - rearm: PreparationRearmHolder + rearm: PreparationRearmHolder, + events: RuntimeWorkspaceCreateEvents ): Promise { if (!this.store) { throw new Error('runtime_unavailable') @@ -124,6 +129,7 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork if (createRoute.kind === 'runtime') { throw new ExecutionHostNotDispatchableError(createRoute.hostId) } + const timing = events.begin(repo.path) if (createRoute.kind === 'ssh') { // `createRoute.repo` carries the resolved connection in `connectionId`, because the // remote-create pipeline still reads `repo.connectionId!` at every depth. See the workaround @@ -134,7 +140,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork ...(effectiveStartup ? { startup: effectiveStartup } : {}), ...(effectiveStartupFollowup ? { startupFollowup: effectiveStartupFollowup } : {}), ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) + ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}), + timing }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) this.emitWorktreeLifecycle({ @@ -178,7 +185,8 @@ export class OrcaRuntimeWithCreateManagedWorktree extends OrcaRuntimeWithGetWork this.fetchRemoteWithCache(path, remote, ...options), onWorktreeMetadataPersisted: (persistedWorktree) => this.recordCreatedWorktreeLineage(persistedWorktree, lineageResolution), - rearm + rearm, + timing }) const settings = createSettings const { lineage, workspaceLineage, warnings: lineageWarnings } = metadataResult diff --git a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts index dbbb124d34a..8ce88334e1b 100644 --- a/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts +++ b/src/main/runtime/orca-runtime-emit-daemon-pty-transient-fact.ts @@ -5,7 +5,17 @@ import type { TerminalSideEffectBatch, TerminalSideEffectFact } from '../../shared/terminal-side-effect-facts' -import { isCursorNativeAgentTitle, normalizeTerminalTitle } from '../../shared/agent-detection' +import { + detectAgentStatusFromTitle, + isCursorNativeAgentTitle, + normalizeTerminalTitle +} from '../../shared/agent-detection' +import { + getLeafDisplayRecord, + getPtyDisplayRecord, + type TitleDisplayClear +} from './runtime-worktree-status-projection' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import type { TerminalTitleFactMeta } from '../../shared/terminal-output-side-effects' export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithScheduleWaitBlockedCheck { @@ -186,9 +196,24 @@ export class OrcaRuntimeWithEmitDaemonPtyTransientFact extends OrcaRuntimeWithSc /** Display fallback for identities intentionally omitted from liveness records. */ protected getTrackedDisplayTitleForPty(ptyId: string): string | null { return ( + this.getPtyTitleDisplayClear(ptyId)?.title ?? this.getTrackedRawTitleForPty(ptyId) ?? this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.getLastNormalizedTitle() ?? null ) } + + /** The stale-working timer's display-only clear of this PTY's native title, while it stands. */ + protected getPtyTitleDisplayClear(ptyId: string | null | undefined): TitleDisplayClear | null { + const clear = ptyId ? this.ptysById.get(ptyId)?.titleDisplayClear : null + return clear ? { ...clear, status: detectAgentStatusFromTitle(clear.title) } : null + } + + protected getPtyDisplayRecord(pty: RuntimePtyWorktreeRecord): RuntimePtyWorktreeRecord { + return getPtyDisplayRecord(pty, this.getPtyTitleDisplayClear(pty.ptyId)) + } + + protected getLeafDisplayRecord(leaf: RuntimeLeafRecord): RuntimeLeafRecord { + return getLeafDisplayRecord(leaf, this.getPtyTitleDisplayClear(leaf.ptyId)) + } } diff --git a/src/main/runtime/orca-runtime-focus-terminal.ts b/src/main/runtime/orca-runtime-focus-terminal.ts index e0a74325918..d7fc9a8d9fe 100644 --- a/src/main/runtime/orca-runtime-focus-terminal.ts +++ b/src/main/runtime/orca-runtime-focus-terminal.ts @@ -77,7 +77,7 @@ export class OrcaRuntimeWithFocusTerminal extends OrcaRuntimeWithWaitForLeafPtyI const parsedPaneKey = parsePaneKey(live.pty.paneKey ?? '') const revealed = await notifier.revealTerminalSession(live.pty.worktreeId, { ptyId: live.pty.ptyId, - title: getLatestPtyTitle(live.pty), + title: getLatestPtyTitle(this.getPtyDisplayRecord(live.pty)), ...(live.pty.launchConfig ? { launchConfig: copySleepingAgentLaunchConfig(live.pty.launchConfig) } : {}), diff --git a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts index a87f6cc03f2..80796cb28bb 100644 --- a/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts +++ b/src/main/runtime/orca-runtime-get-terminal-interactive-wait.ts @@ -6,6 +6,7 @@ import type { RuntimeTerminalInteractiveWait } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' +import type { AgentStatus } from '../../shared/agent-detection' import { withTimeout } from './runtime-async-boundaries' import { TERMINAL_INTERACTIVE_WAIT_PROBE_TIMEOUT_MS } from './orca-runtime-core' import { parsePaneKey } from '../../shared/stable-pane-id' @@ -20,18 +21,22 @@ export class OrcaRuntimeWithGetTerminalInteractiveWait extends OrcaRuntimeWithAd handle: string ): Promise { let ptyId: string - let terminal: RuntimeTerminalAgentStatusSnapshot + let inputs: { + terminal: RuntimeTerminalAgentStatusSnapshot + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + } try { ptyId = this.getTerminalAgentStatusPtyId(handle) - terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId) + inputs = this.getTerminalWaitPermissionInputs(handle, ptyId) } catch { return undefined } + const { terminal, lifecycle } = inputs const explicitStatus = this.getFreshExplicitAgentStatusForHandle(handle) const promptReason = this.resolveAuthoritativeTerminalWaitPermission( terminal, explicitStatus, - this.agentPromptLifecycleByPtyId.get(ptyId) + lifecycle ) if (promptReason) { return { diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 2e6f30770b0..1fb48756300 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -49,7 +49,10 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime // app relaunch the PTY/leaf records can already hold a persisted title; a // cold tracker would miss the parked working→idle completion and never // arm the stale-title timer for a persisted 'working' title. - let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null + // Why the display clear first: a tracker recreated after a relay drop must not re-arm on the + // working title the timer already retired. + const pty = this.ptysById.get(ptyId) + let initialTitle = pty?.titleDisplayClear?.title ?? pty?.lastOscTitle ?? null if (initialTitle === null) { for (const leaf of this.getLeavesForPty(ptyId)) { if (leaf.lastOscTitle) { diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index b25ce381def..4333cc7e3a5 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -78,6 +78,7 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis tabs: this.tabs, session, getPaneKey: (leaf) => this.makeRuntimePaneKey(leaf), + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId), getSummary: (summaryMap, pathIndex, missingIds, worktreeId) => this.getSummaryForRuntimeWorktreeId(summaryMap, pathIndex, missingIds, worktreeId) }) diff --git a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts index 7aea8a0d0d7..265f2ac28c0 100644 --- a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts +++ b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts @@ -180,7 +180,7 @@ export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends Orc } const reveal = await this.notifier.revealTerminalSession(candidate.worktreeId, { ptyId: candidate.ptyId, - title: getLatestPtyTitle(pty) ?? pty.controllerTitle, + title: getLatestPtyTitle(this.getPtyDisplayRecord(pty)) ?? pty.controllerTitle, activate: false, presentation: 'background', tabId: candidate.tabId, diff --git a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts index 665a5ef2253..40706f4730f 100644 --- a/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts +++ b/src/main/runtime/orca-runtime-maybe-hydrate-headless-from-renderer.ts @@ -127,6 +127,8 @@ export class OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer extends OrcaRuntime // once a live title was observed, so live state always wins. this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) const status = detectAgentStatusFromTitle(title) + // Why evidence, not display: display readers project a stale-working clear over the record, + // so re-seeding the native working title cannot bring a cleared spinner back. // Why: live observations store normalized titles, so seeds must match — // otherwise the first live frame after hydration compares unequal and // touches session tabs once for no visible change. diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 75005726c2d..42a4dadd25f 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -99,6 +99,7 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime getRetainedStatus: (paneKey, pty, tab, getRows) => this.getFreshRetainedAgentStatusForMobileTab(paneKey, pty, tab, getRows), getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId), + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId), issuePtyHandle: (pty) => this.issuePtyHandle(pty), recordPty: (ptyId, worktreeId, state) => this.recordPtyWorktree(ptyId, worktreeId, state), buildPtyStatus: (pty, tab, terminalHandle, retained, getRows) => @@ -117,12 +118,23 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime retained: RuntimeAgentRowSnapshot | null, getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { - return buildRuntimeMobileAgentStatus(pty, tab, terminalHandle, retained, getHookRowsForPane, { - getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), - getLeaf: (candidate) => - this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) ?? null, - getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) - }) + // Why display records: a phone status is presentation, so it shows the stale-working clear. + const displayPty = pty ? this.getPtyDisplayRecord(pty) : null + return buildRuntimeMobileAgentStatus( + displayPty, + tab, + terminalHandle, + retained, + getHookRowsForPane, + { + getPaneKey: (candidate) => this.getMobileTerminalPaneKey(candidate), + getLeaf: (candidate) => { + const leaf = this.leaves.get(this.getLeafKey(candidate.parentTabId, candidate.leafId)) + return leaf ? this.getLeafDisplayRecord(leaf) : null + }, + getTrackedTitle: (ptyId) => this.getUnpersistedTrackedTitleForPty(ptyId) + } + ) } protected getFreshRetainedAgentStatusForMobileTab( diff --git a/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts index 778128d7736..ea220e9a4b4 100644 --- a/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts @@ -44,7 +44,7 @@ export class OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal extends OrcaRu const existing = this.mobileSessionTabsByWorktree.get(worktreeId) const ownerAgent = pty.launchAgent ?? pty.foregroundAgent const title = normalizeCompatibleAgentTitleForOwner( - args.title ?? getLatestPtyTitle(pty) ?? 'Terminal', + args.title ?? getLatestPtyTitle(this.getPtyDisplayRecord(pty)) ?? 'Terminal', ownerAgent, { ownerIsLaunch: Boolean(pty.launchAgent) } ) diff --git a/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts b/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts index 999f23d6ad3..001f5f4ebff 100644 --- a/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts +++ b/src/main/runtime/orca-runtime-record-agent-prompt-lifecycle-state.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector } from './orca-runtime-create-terminal-side-effect-command-code-detector' import type { AgentStatus } from '../../shared/agent-detection' +import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection' import { findLastCompleteOscTitleRange } from './orca-runtime-core' import { extractLastOscTitle } from '../../shared/osc-title-extraction' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -13,6 +14,12 @@ export class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeW this.recordAgentPromptPermissionObservation(ptyId) } const current = this.agentPromptLifecycleByPtyId.get(ptyId) + // Why projected: main recorded a stale-working clear here, so the next working status after + // one starts a new turn for prompt verification, as it did there. + const previousStatus = getDisplayPromptLifecycle( + current, + this.getPtyTitleDisplayClear(ptyId) + )?.status const updatedAt = Date.now() if (!current) { this.agentPromptLifecycleByPtyId.set(ptyId, { @@ -25,7 +32,7 @@ export class OrcaRuntimeWithRecordAgentPromptLifecycleState extends OrcaRuntimeW this.agentPromptLifecycleByPtyId.set(ptyId, { status, workingSequence: - current.workingSequence + (status === 'working' && current.status !== 'working' ? 1 : 0), + current.workingSequence + (status === 'working' && previousStatus !== 'working' ? 1 : 0), updatedAt }) } diff --git a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts index a86c95d8742..d7aa946f477 100644 --- a/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts +++ b/src/main/runtime/orca-runtime-resolve-authoritative-terminal-wait-permission.ts @@ -8,6 +8,7 @@ import { isOpenCodeNativeTitle } from '../../shared/agent-detection' import type { AgentStatusEntry, AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { renewRuntimeMobileAgentStatusFromPtyTitle } from './runtime-mobile-agent-status-projection' +import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection' import type { RuntimeTerminalWriteOptions } from './runtime-terminal-writer' import { getRegisteredSshState } from '../ssh/ssh-target-registry' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' @@ -50,6 +51,28 @@ export class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission extends O return newestPermissionAt >= 0 && newestPermissionAt >= newestClearAt ? blockedByWaitText : null } + /** + * The title snapshot and prompt lifecycle the blocked-dialog check compares, both as display + * shows them, so the check reaches main's verdict: main compared the pair a stale-working clear + * left, and comparing a renderer's echoed clear with the native lifecycle hid a later dialog. + */ + protected getTerminalWaitPermissionInputs( + handle: string, + ptyId: string, + waitTextOverride?: string + ): { + terminal: RuntimeTerminalAgentStatusSnapshot + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined + } { + const clear = this.getPtyTitleDisplayClear(ptyId) + const snapshot = this.terminalAgentStatus.getSnapshot(handle, ptyId, clear) + return { + terminal: + waitTextOverride === undefined ? snapshot : { ...snapshot, waitText: waitTextOverride }, + lifecycle: getDisplayPromptLifecycle(this.agentPromptLifecycleByPtyId.get(ptyId), clear) + } + } + /** The pane's main-agent turn from the hook server's store, for tui-idle's hook lane. */ protected readTuiIdleHookTurnForPty(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null { const pty = this.ptysById.get(ptyId) diff --git a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts index f7ead603e02..2bfce095ff4 100644 --- a/src/main/runtime/orca-runtime-resolve-exit-waiters.ts +++ b/src/main/runtime/orca-runtime-resolve-exit-waiters.ts @@ -11,13 +11,8 @@ import { import type { AgentStatus } from '../../shared/agent-detection' import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' import { buildTerminalWaitText } from './terminal-wait-tail-state' -import { - evaluateTuiIdle, - isTuiIdleReadyVerdict, - leafTuiIdleEvidence, - ptyTuiIdleEvidence, - type TuiIdleVerdict -} from './tui-idle-evidence' +import { evaluateTuiIdle, isTuiIdleReadyVerdict, type TuiIdleVerdict } from './tui-idle-evidence' +import { leafTuiIdleEvidence, ptyTuiIdleEvidence } from './tui-idle-evidence-source' import { TUI_IDLE_QUIESCENCE_MS } from './orca-runtime-postlude' export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyIncarnationHandle { @@ -218,7 +213,7 @@ export class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithBindPtyInc } protected getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { - const title = this.getAdoptedPtyTitle(pty) + const title = pty.lastOscTitle ?? this.getAdoptedPtyTitle(pty) return title ? detectExplicitIdleStatusFromTitle(title) : null } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index dbdec24d0fc..5a471276d85 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -47,7 +47,7 @@ import { RuntimeTerminalWriter } from './runtime-terminal-writer' import { RuntimeTerminalIdlePolls } from './runtime-terminal-idle-polls' import { TerminalIntentionalStops } from './terminal-intentional-stops' import { TerminalRunFactsRegister, type TerminalSpawnCommit } from './terminal-run-facts' -import type { TuiIdleEvidenceSource } from './tui-idle-evidence' +import type { TuiIdleEvidenceSource } from './tui-idle-evidence-source' import { hasTerminalCommandPainted } from './terminal-command-paint' import { TUI_IDLE_DEFAULT_TIMEOUT_MS, @@ -350,7 +350,9 @@ export class OrcaRuntimeWithRuntimeId { (ptyId ? this.ptysById.get(ptyId)?.lastExplicitAgentStatus : null) ?? null, getHookTurn: (ptyId, agent) => this.readTuiIdleHookTurnForPty(ptyId, agent), readScreenLines: (ptyId) => this.readLiveTerminalScreenLines(ptyId), - readScreenRuledLines: (ptyId) => this.readScreenRuledLines(ptyId) + readRuledScreen: (ptyId) => this.readRuledScreen(ptyId), + getTitleObservedAtEpochMs: (ptyId) => + (ptyId ? this.ptysById.get(ptyId)?.lastOscTitleEpochMs : null) ?? null } protected readonly terminalIdlePolls = new RuntimeTerminalIdlePolls({ diff --git a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts index 99a3a56943d..8bfd39bfa22 100644 --- a/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts +++ b/src/main/runtime/orca-runtime-serialize-agent-prompt-submission.ts @@ -3,6 +3,7 @@ import { selectFreshExplicitAgentStatus } from './runtime-hook-agent-row-selecti import { OrcaRuntimeWithControllerKnowsPtyIsLive } from './orca-runtime-controller-knows-pty-is-live' import type { RuntimeTerminalAgentStatus } from '../../shared/runtime-types' import type { RuntimeTerminalAgentStatusSnapshot } from './runtime-terminal-agent-status-query' +import { getDisplayPromptLifecycle } from './runtime-worktree-status-projection' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { hasCompatibleAgentTitleIdentity } from '../../shared/agent-title-owner' import type { PtyForegroundProcessRead } from './runtime-terminal-contracts' @@ -124,7 +125,8 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi this.resolvePtyTuiIdleWaiters(current, ptyId) } for (const leaf of this.getLeavesForPty(ptyId)) { - if (leaf.lastAgentStatus !== null) { + // Why the clear too: a stale-working clear leaves the native status, not the neutral one. + if (leaf.lastAgentStatus !== null && !current.titleDisplayClear) { continue } // Why: the live agent disproved the neutral title's exit signal; keep runtime delivery state aligned with the restored tracker. @@ -263,7 +265,12 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi const outputSequence = this.getPtyOutputSequence(ptyId) const explicit = this.getFreshExplicitAgentStatusForPty(handle, ptyId) const explicitFloor = this.agentPromptExplicitStatusFloorByPtyId.get(ptyId) - const lifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + const nativeLifecycle = this.agentPromptLifecycleByPtyId.get(ptyId) + // Why projected: verification compares against main's baseline, which held a stale-working clear. + const lifecycle = getDisplayPromptLifecycle( + nativeLifecycle, + this.getPtyTitleDisplayClear(ptyId) + ) const ptyStatus = lifecycle || explicitFloor === undefined ? (this.ptysById.get(ptyId)?.lastAgentStatus ?? null) @@ -280,8 +287,12 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi () => this.getTerminalAgentStatusSnapshot(handle, ptyId).waitText ) : undefined - const terminal = this.getTerminalAgentStatusSnapshot(handle, ptyId, waitText) - const status = this.hasAuthoritativeTerminalWaitPermission(terminal, explicit, lifecycle) + const waitInputs = this.getTerminalWaitPermissionInputs(handle, ptyId, waitText) + const status = this.hasAuthoritativeTerminalWaitPermission( + waitInputs.terminal, + explicit, + waitInputs.lifecycle + ) ? 'permission' : lifecycleIsNewer ? lifecycle.status @@ -289,7 +300,7 @@ export class OrcaRuntimeWithSerializeAgentPromptSubmission extends OrcaRuntimeWi return { generation: this.getPtyLifecycleGeneration(ptyId), permissionSequence: this.agentPromptPermissionSequenceByPtyId.get(ptyId) ?? 0, - workingSequence: lifecycle?.workingSequence ?? 0, + workingSequence: nativeLifecycle?.workingSequence ?? 0, explicitWorkingStartedAt: explicit?.status === 'working' ? explicit.stateStartedAt : null, outputSequence, status diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 1e54583435c..4e8087e5a95 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -52,7 +52,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getPrimaryLeaf: (ptyId) => this.getLeavesForPty(ptyId)[0] ?? null, getTrackedPty: (ptyId) => this.ptysById.get(ptyId) ?? null, getTabTitle: (tabId) => this.tabs.get(tabId)?.title?.trim() || null, - getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null + getForegroundProcess: (ptyId) => this.ptyController?.getForegroundProcess(ptyId) ?? null, + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId) }) protected notifier: RuntimeNotifier | null = null @@ -200,7 +201,8 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId getTabTitle: (tabId) => this.tabs.get(tabId)?.title ?? null, getExplicitStatus: (handle) => this.getFreshExplicitAgentStatusForHandle(handle), getLifecycleStatus: (ptyId) => this.agentPromptLifecycleByPtyId.get(ptyId), - isRunning: (handle) => this.isTerminalRunningAgent(handle) + isRunning: (handle) => this.isTerminalRunningAgent(handle), + getTitleDisplayClear: (ptyId) => this.getPtyTitleDisplayClear(ptyId) }) protected _orchestrationDb: OrchestrationDb | null = null diff --git a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts index 64cda7b73f2..fd53d34f9a3 100644 --- a/src/main/runtime/orca-runtime-visible-snapshot-preview.ts +++ b/src/main/runtime/orca-runtime-visible-snapshot-preview.ts @@ -12,6 +12,7 @@ import { } from './orca-runtime-postlude' import { projectTerminalVisibleLines } from './orca-runtime-terminal-projection' import { visibleNonBlankTerminalLines } from './terminal-tail-read' +import type { RuledScreen } from './screen-input-veto' import { HeadlessEmulator } from '../daemon/headless-emulator' import { withTimeout } from './runtime-async-boundaries' @@ -152,8 +153,8 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur return state ? projectTerminalVisibleLines(state.emulator).lines : null } - /** The rows a screen-ruled agent's rule reads: as painted, and only on the PTY's own grid. */ - protected readScreenRuledLines(ptyId: string | null | undefined): string[] | null { + /** The grid a screen-ruled agent's rule reads: as painted, and only on the PTY's own size. */ + protected readRuledScreen(ptyId: string | null | undefined): RuledScreen | null { const state = this.readWholeScreenModel(ptyId) if (!ptyId || !state || state.unrepaintedReflowGrid !== undefined) { return null @@ -166,7 +167,10 @@ export class OrcaRuntimeWithVisibleSnapshotPreview extends OrcaRuntimeWithCaptur } // Why raw rows, not the read projection: it blanks a composer it takes for a draft, and Cline's // placeholder reads as one, so a typed draft and an empty composer would look the same. - return visibleNonBlankTerminalLines(state.emulator.getVisibleLines()) + return { + lines: visibleNonBlankTerminalLines(state.emulator.getVisibleLines()), + alternateScreen: state.emulator.isAlternateScreen + } } protected readWholeScreenModel(ptyId: string | null | undefined): RuntimeHeadlessTerminal | null { diff --git a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts index 538ab4d6d4d..66d6e50335d 100644 --- a/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts +++ b/src/main/runtime/orca-runtime-write-orchestration-pointer-pty.ts @@ -148,7 +148,7 @@ export class OrcaRuntimeWithWriteOrchestrationPointerPty extends OrcaRuntimeWith const tab = this.tabs.get(leaf.tabId) ?? null const pty = leaf.ptyId ? this.ptysById.get(leaf.ptyId) : undefined - const title = getLatestLeafTitle(leaf, tab?.title ?? null) + const title = getLatestLeafTitle(this.getLeafDisplayRecord(leaf), tab?.title ?? null) // Why: leaf.connected mirrors the renderer graph (`ptyId !== null`), so a // restored surface whose PTY died with a prior run still reads connected. // Demote only on a controller-proven absence, and only for locally-scoped diff --git a/src/main/runtime/runtime-local-git-worktree-create.ts b/src/main/runtime/runtime-local-git-worktree-create.ts index ccefaa82255..353375999e7 100644 --- a/src/main/runtime/runtime-local-git-worktree-create.ts +++ b/src/main/runtime/runtime-local-git-worktree-create.ts @@ -24,6 +24,7 @@ import { failedWorktreeCreationNeedsRetirement, retireGeneratedWorktreeName } from '../worktree-name-retirement' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' export async function createRuntimeLocalGitWorktree(args: { request: RuntimeManagedWorktreeCreateArgs @@ -59,6 +60,7 @@ export async function createRuntimeLocalGitWorktree(args: { ) => Promise fetchRemote: (repoPath: string, remote: string, options?: LocalGitExecOptions) => Promise rearm: PreparationRearmHolder + timing: WorktreeCreateTimingRecorder }): Promise<{ remoteTrackingBase: RemoteTrackingBase | null sparseDirectories: string[] @@ -80,10 +82,9 @@ export async function createRuntimeLocalGitWorktree(args: { if (!hadRemoteRef && hasLocalBase) { remoteTrackingBase = null } else { - const refresh = await args.refreshRemoteTrackingBase( - args.repo.path, - remoteTrackingBase, - args.localWorktreeGitOptions + const base = remoteTrackingBase + const refresh = await args.timing.time('refresh_base_ref', () => + args.refreshRemoteTrackingBase(args.repo.path, base, args.localWorktreeGitOptions) ) if (!refresh.ok && !hadRemoteRef) { throw new Error( @@ -105,7 +106,9 @@ export async function createRuntimeLocalGitWorktree(args: { !(await hasLocalWorktreeBaseRef(args.repo.path, args.baseBranch, args.localWorktreeGitOptions)) ) { try { - await args.fetchRemote(args.repo.path, 'origin', args.localWorktreeGitOptions) + await args.timing.time('refresh_base_ref', () => + args.fetchRemote(args.repo.path, 'origin', args.localWorktreeGitOptions) + ) } catch {} } const sparseDirectories = args.request.sparseCheckout @@ -139,40 +142,47 @@ export async function createRuntimeLocalGitWorktree(args: { isGeneratedWorktreeCreateName(args.effectiveSanitizedName!) let addResult: AddWorktreeResult try { - const preparedAttempt = - sparseDirectories.length === 0 && !args.checkoutExistingBranch - ? await consumePreparedWorktreeCreate({ - repoPath: args.repo.path, - workspaceRoot: args.workspaceRoot, - worktreePath: args.worktreePath, - branch: args.branchName, - baseBranch: args.baseBranch, - refreshLocalBaseRef: args.settings.refreshLocalBaseRefOnWorktreeCreate, - options: preparedWorktreeOptions - }) - : null - // This path has no create-span recorder, so the miss reason is only observable on the IPC path. - if (preparedAttempt?.status === 'miss' && preparedAttempt.rearm) { - args.rearm.fire = preparedAttempt.rearm - } - if (preparedAttempt?.status === 'hit') { - addResult = preparedAttempt.result - // Deferred, not fired: re-arming is a full `reset --hard`, and the caller still has - // materialization probes and terminals ahead of it. - args.rearm.fire = preparedAttempt.rearm - } else if (sparseDirectories.length > 0) { - addResult = - (await addSparseWorktree( - args.repo.path, - args.worktreePath, - args.branchName, - sparseDirectories, - args.baseBranch, - args.settings.refreshLocalBaseRefOnWorktreeCreate, - addOptions - )) ?? {} - } else { - addResult = + addResult = await args.timing.time('git_worktree_add', async () => { + if (sparseDirectories.length > 0 || args.checkoutExistingBranch) { + args.timing.recordPreparedCheckout({ + status: 'miss', + reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch' + }) + } else { + const preparedAttempt = await consumePreparedWorktreeCreate({ + repoPath: args.repo.path, + workspaceRoot: args.workspaceRoot, + worktreePath: args.worktreePath, + branch: args.branchName, + baseBranch: args.baseBranch, + refreshLocalBaseRef: args.settings.refreshLocalBaseRefOnWorktreeCreate, + options: preparedWorktreeOptions, + timing: args.timing + }) + if (preparedAttempt.status === 'hit') { + // Deferred, not fired: re-arming is a full `reset --hard`, and the caller still has + // materialization probes and terminals ahead of it. + args.rearm.fire = preparedAttempt.rearm + return preparedAttempt.result + } + if (preparedAttempt.rearm) { + args.rearm.fire = preparedAttempt.rearm + } + } + if (sparseDirectories.length > 0) { + return ( + (await addSparseWorktree( + args.repo.path, + args.worktreePath, + args.branchName, + sparseDirectories, + args.baseBranch, + args.settings.refreshLocalBaseRefOnWorktreeCreate, + addOptions + )) ?? {} + ) + } + return ( (await addWorktree( args.repo.path, args.worktreePath, @@ -182,7 +192,8 @@ export async function createRuntimeLocalGitWorktree(args: { false, addOptions )) ?? {} - } + ) + }) } catch (error) { if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) { await retireGeneratedWorktreeName( @@ -216,12 +227,19 @@ export async function createRuntimeLocalGitWorktree(args: { args.localWorktreeGitOptions ) : preparedPushTarget - const { created } = await resolveCreatedWorktree( - args.repo.path, - args.worktreePath, - args.branchName, - args.localWorktreeGitOptions + const { created, worktrees, listingComplete } = await args.timing.time( + 'list_created_worktree', + () => + resolveCreatedWorktree( + args.repo.path, + args.worktreePath, + args.branchName, + args.localWorktreeGitOptions + ) ) + if (listingComplete) { + args.timing.recordWorktreeCount(worktrees.length) + } return { remoteTrackingBase, sparseDirectories, diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index c673da99640..c89ff71e996 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -82,12 +82,17 @@ vi.mock('../ipc/worktree-symlinks', () => ({ import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create' import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + createWorktreeCreateTimingRecorder, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' const worktreePath = resolve('/worktrees', 'app') function createWorktree( request: Partial = {}, - rearm: PreparationRearmHolder = { fire: () => {} } + rearm: PreparationRearmHolder = { fire: () => {} }, + timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder() ) { const store = { getSettings: () => ({ @@ -109,7 +114,8 @@ function createWorktree( refreshRemoteTrackingBase: mocks.refresh, fetchRemote: mocks.fetch, onWorktreeMetadataPersisted: () => undefined, - rearm + rearm, + timing }) } @@ -216,6 +222,41 @@ describe('runtime prepared-worktree replenishment', () => { }) }) +describe('runtime create timing', () => { + it('times the add with the prepared-checkout consume inside it, and lists the worktrees', async () => { + mocks.listing.mockResolvedValue({ + created: mocks.created, + worktrees: [mocks.created, mocks.created, mocks.created], + listingComplete: true + }) + const timing = createWorktreeCreateTimingRecorder() + + await createWorktree({}, undefined, timing) + + expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ timing })) + const finished = timing.finish() + expect(finished.phases.map((phase) => phase.phase)).toEqual( + expect.arrayContaining([ + 'resolve_name', + 'git_worktree_add', + 'list_created_worktree', + 'persist_metadata', + 'copy_worktreeinclude' + ]) + ) + expect(finished).toMatchObject({ executionHost: 'local', worktreeCount: 3 }) + }) + + it('records a sparse create as a miss without consulting the prepared checkout', async () => { + const timing = createWorktreeCreateTimingRecorder() + + await createWorktree({ sparseCheckout: { directories: ['src'] } }, undefined, timing) + + expect(mocks.consume).not.toHaveBeenCalled() + expect(timing.finish().preparedCheckout).toEqual({ status: 'miss', reason: 'sparse_checkout' }) + }) +}) + describe('runtime create Git priority', () => { it.each([undefined, 'Ubuntu'])( 'preserves interactive priority and routing on %s', diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index 41e7b3734fc..a19a62ee29e 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -20,6 +20,10 @@ import { resolveRuntimeLocalWorktreeCreateCandidate } from './runtime-local-work import { createRuntimeLocalGitWorktree } from './runtime-local-git-worktree-create' import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' import type { PreparationRearmHolder } from '../worktree-create-preparation' +import { + localWorktreeCreateExecutionHost, + type WorktreeCreateTimingRecorder +} from '../worktree-create-timing' type RuntimeLocalWorktreeCreateArgs = { request: RuntimeManagedWorktreeCreateArgs @@ -45,6 +49,7 @@ type RuntimeLocalWorktreeCreateArgs = { fetchRemote: (path: string, remote: string, options?: LocalGitExecOptions) => Promise onWorktreeMetadataPersisted: (worktree: Worktree) => T rearm: PreparationRearmHolder + timing: WorktreeCreateTimingRecorder } export function createRuntimeLocalManagedWorktree(args: RuntimeLocalWorktreeCreateArgs) { @@ -57,6 +62,7 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr const pathSettings = getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo)) const gitExecOptions = getLocalProjectGitExecOptions(store, repo) const worktreeGitOptions = getLocalProjectWorktreeGitOptions(store, repo) + args.timing.recordExecutionHost(localWorktreeCreateExecutionHost(gitExecOptions)) // Username and base resolution are independent read-only probes. Starting // both before awaiting removes one serial git/config round trip from create. const usernamePromise = @@ -88,18 +94,20 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr 'Could not resolve a default base ref for this repo. Pass an explicit --base and try again.' ) } - const candidate = await resolveRuntimeLocalWorktreeCreateCandidate({ - request, - repo, - settings, - worktreePathSettings: pathSettings, - workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), - username, - store, - baseBranch, - localWorktreeGitOptions: worktreeGitOptions, - hostedReviewExecutionContext: args.hostedReviewExecutionContext - }) + const candidate = await args.timing.time('resolve_name', () => + resolveRuntimeLocalWorktreeCreateCandidate({ + request, + repo, + settings, + worktreePathSettings: pathSettings, + workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), + username, + store, + baseBranch, + localWorktreeGitOptions: worktreeGitOptions, + hostedReviewExecutionContext: args.hostedReviewExecutionContext + }) + ) const git = await createRuntimeLocalGitWorktree({ request, repo, @@ -116,7 +124,8 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr hasRemoteTrackingRef: args.hasRemoteTrackingRef, refreshRemoteTrackingBase: args.refreshRemoteTrackingBase, fetchRemote: args.fetchRemote, - rearm: args.rearm + rearm: args.rearm, + timing: args.timing }) const materialized = await materializeRuntimeLocalWorktree({ request, @@ -136,7 +145,8 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr effectiveSanitizedName: candidate.effectiveSanitizedName, effectiveCreatedWithAgent: args.createdWithAgent, localWorktreeGitOptions: worktreeGitOptions, - onMetadataPersisted: args.onWorktreeMetadataPersisted + onMetadataPersisted: args.onWorktreeMetadataPersisted, + timing: args.timing }) return { ...materialized, diff --git a/src/main/runtime/runtime-local-worktree-materialization.test.ts b/src/main/runtime/runtime-local-worktree-materialization.test.ts index 85377b7ac40..4966ac862f8 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.test.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.test.ts @@ -21,6 +21,7 @@ vi.mock('../git/worktree-shared-directories', () => ({ })) import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' +import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing' describe('materializeRuntimeLocalWorktree', () => { it('records lineage immediately after metadata and before filesystem setup', async () => { @@ -65,7 +66,8 @@ describe('materializeRuntimeLocalWorktree', () => { onMetadataPersisted: () => { order.push('metadata') return null - } + }, + timing: createWorktreeCreateTimingRecorder() } as never) ).rejects.toThrow('link failed') diff --git a/src/main/runtime/runtime-local-worktree-materialization.ts b/src/main/runtime/runtime-local-worktree-materialization.ts index 1919f69b780..9347a7e7ffc 100644 --- a/src/main/runtime/runtime-local-worktree-materialization.ts +++ b/src/main/runtime/runtime-local-worktree-materialization.ts @@ -22,6 +22,7 @@ import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directo import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' import type { RemoteTrackingBase } from './runtime-remote-fetch-controller' import type { RuntimeStore } from './runtime-store-contract' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' export async function materializeRuntimeLocalWorktree(args: { request: RuntimeManagedWorktreeCreateArgs @@ -42,6 +43,7 @@ export async function materializeRuntimeLocalWorktree(args: { effectiveCreatedWithAgent?: TuiAgent localWorktreeGitOptions: LocalGitExecOptions onMetadataPersisted: (worktree: Worktree) => T + timing: WorktreeCreateTimingRecorder }): Promise<{ worktree: Worktree; metadataResult: T; includeCopyWarning?: string }> { const { request, @@ -60,7 +62,8 @@ export async function materializeRuntimeLocalWorktree(args: { displayNameKind, effectiveSanitizedName, effectiveCreatedWithAgent, - localWorktreeGitOptions + localWorktreeGitOptions, + timing } = args const worktreeId = `${repo.id}::${created.path}` const now = Date.now() @@ -71,62 +74,66 @@ export async function materializeRuntimeLocalWorktree(args: { displayNameKind, { requestedName: effectiveRequestedName, sanitizedName: effectiveSanitizedName } ) - const meta = store.setWorktreeMeta(worktreeId, { - instanceId: randomUUID(), - ...getProjectHostSetupWorktreeMeta(store.getProjectHostSetups?.() ?? [], repo), - lastActivityAt: now, - createdAt: now, - orcaCreatedAt: now, - orcaCreationSource: 'runtime', - orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), - ...displayNameMeta, - baseRef: metadataBaseRef, - ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), - ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), - ...(sparseDirectories.length > 0 - ? { - sparseDirectories, - sparseBaseRef: metadataBaseRef, - sparsePresetId: request.sparseCheckout?.presetId - } - : {}), - ...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}), - ...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}), - ...(request.linkedLinearIssue !== undefined - ? { linkedLinearIssue: request.linkedLinearIssue } - : {}), - ...(request.linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId: request.linkedLinearIssueWorkspaceId } - : {}), - ...(request.linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey: request.linkedLinearIssueOrganizationUrlKey } - : {}), - ...(request.linkedGitLabIssue !== undefined - ? { linkedGitLabIssue: request.linkedGitLabIssue } - : {}), - ...(request.linkedGitLabMR !== undefined ? { linkedGitLabMR: request.linkedGitLabMR } : {}), - ...(request.linkedBitbucketPR !== undefined - ? { linkedBitbucketPR: request.linkedBitbucketPR } - : {}), - ...(request.linkedAzureDevOpsPR !== undefined - ? { linkedAzureDevOpsPR: request.linkedAzureDevOpsPR } - : {}), - ...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}), - ...(request.linkedWorkItem !== undefined ? { linkedWorkItem: request.linkedWorkItem } : {}), - ...(request.linkedTaskSourceContext !== undefined - ? { linkedTaskSourceContext: request.linkedTaskSourceContext } - : {}), - ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), - ...(request.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(request.automationProvenance ? { automationProvenance: request.automationProvenance } : {}), - ...(request.cliProvenance ? { cliProvenance: request.cliProvenance } : {}), - creatorProvenance: request.creatorProvenance ?? { kind: 'host' }, - ...(request.comment !== undefined ? { comment: request.comment } : {}), - ...(request.manualOrder !== undefined ? { manualOrder: request.manualOrder } : {}), - ...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}) - }) + const meta = timing.timeSync('persist_metadata', () => + store.setWorktreeMeta(worktreeId, { + instanceId: randomUUID(), + ...getProjectHostSetupWorktreeMeta(store.getProjectHostSetups?.() ?? [], repo), + lastActivityAt: now, + createdAt: now, + orcaCreatedAt: now, + orcaCreationSource: 'runtime', + orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings), + ...displayNameMeta, + baseRef: metadataBaseRef, + ...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}), + ...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: metadataBaseRef, + sparsePresetId: request.sparseCheckout?.presetId + } + : {}), + ...(request.linkedIssue !== undefined ? { linkedIssue: request.linkedIssue } : {}), + ...(request.linkedPR !== undefined ? { linkedPR: request.linkedPR } : {}), + ...(request.linkedLinearIssue !== undefined + ? { linkedLinearIssue: request.linkedLinearIssue } + : {}), + ...(request.linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId: request.linkedLinearIssueWorkspaceId } + : {}), + ...(request.linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey: request.linkedLinearIssueOrganizationUrlKey } + : {}), + ...(request.linkedGitLabIssue !== undefined + ? { linkedGitLabIssue: request.linkedGitLabIssue } + : {}), + ...(request.linkedGitLabMR !== undefined ? { linkedGitLabMR: request.linkedGitLabMR } : {}), + ...(request.linkedBitbucketPR !== undefined + ? { linkedBitbucketPR: request.linkedBitbucketPR } + : {}), + ...(request.linkedAzureDevOpsPR !== undefined + ? { linkedAzureDevOpsPR: request.linkedAzureDevOpsPR } + : {}), + ...(request.linkedGiteaPR !== undefined ? { linkedGiteaPR: request.linkedGiteaPR } : {}), + ...(request.linkedWorkItem !== undefined ? { linkedWorkItem: request.linkedWorkItem } : {}), + ...(request.linkedTaskSourceContext !== undefined + ? { linkedTaskSourceContext: request.linkedTaskSourceContext } + : {}), + ...(effectiveCreatedWithAgent ? { createdWithAgent: effectiveCreatedWithAgent } : {}), + ...(request.pendingFirstAgentMessageRename === true && effectiveCreatedWithAgent + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(request.automationProvenance + ? { automationProvenance: request.automationProvenance } + : {}), + ...(request.cliProvenance ? { cliProvenance: request.cliProvenance } : {}), + creatorProvenance: request.creatorProvenance ?? { kind: 'host' }, + ...(request.comment !== undefined ? { comment: request.comment } : {}), + ...(request.manualOrder !== undefined ? { manualOrder: request.manualOrder } : {}), + ...(request.workspaceStatus !== undefined ? { workspaceStatus: request.workspaceStatus } : {}) + }) + ) const worktree = { ...mergeWorktree(repo.id, created, meta), hostId: meta.hostId ?? getRepoExecutionHostId(repo) @@ -134,24 +141,30 @@ export async function materializeRuntimeLocalWorktree(args: { const metadataResult = args.onMetadataPersisted(worktree) if ((repo.symlinkPaths ?? []).length > 0) { - await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths ?? []) + await timing.time('create_symlinks', () => + createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths ?? []) + ) } // These discoveries are read-only; overlap them, but keep the shared-path // mutation ahead of include copies below. const [sharedDirectories, worktreeIncludePaths] = await Promise.all([ - resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions), - resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + timing.time('resolve_shared_directories', () => + resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions) + ), + timing.time('resolve_worktreeinclude', () => + resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions) + ) ]) if (sharedDirectories.length > 0) { - await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) + await timing.time('create_shared_directories', () => + createWorktreeSharedPaths(repo.path, created.path, sharedDirectories) + ) } if (worktreeIncludePaths.length === 0) { return { worktree, metadataResult } } - const skippedIncludePaths = await createWorktreeCopiedPaths( - repo.path, - created.path, - worktreeIncludePaths + const skippedIncludePaths = await timing.time('copy_worktreeinclude', () => + createWorktreeCopiedPaths(repo.path, created.path, worktreeIncludePaths) ) const includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths) if (includeCopyWarning) { diff --git a/src/main/runtime/runtime-mobile-session-projection-contract.ts b/src/main/runtime/runtime-mobile-session-projection-contract.ts index f4174b7715a..d2cb14123f2 100644 --- a/src/main/runtime/runtime-mobile-session-projection-contract.ts +++ b/src/main/runtime/runtime-mobile-session-projection-contract.ts @@ -10,6 +10,7 @@ import type { import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { RuntimeAgentRowSnapshot } from './runtime-worktree-agent-rows' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import type { TitleDisplayClear } from './runtime-worktree-status-projection' export type RuntimeMobileSessionProjectionHost = { tabs: ReadonlyMap @@ -32,6 +33,8 @@ export type RuntimeMobileSessionProjectionHost = { getRows: (paneKey: string, terminalHandle: string | null) => AgentStatusIpcPayload[] ): RuntimeAgentRowSnapshot | null getTrackedTitle(ptyId: string | null): string | null + /** Display-only clear of a PTY's native title; every title/status read here is display. */ + getTitleDisplayClear(ptyId: string | null): TitleDisplayClear | null issuePtyHandle(pty: RuntimePtyWorktreeRecord): string recordPty( ptyId: string, diff --git a/src/main/runtime/runtime-mobile-session-projection.ts b/src/main/runtime/runtime-mobile-session-projection.ts index 969a2946c9a..990039940c8 100644 --- a/src/main/runtime/runtime-mobile-session-projection.ts +++ b/src/main/runtime/runtime-mobile-session-projection.ts @@ -20,6 +20,8 @@ import { finalizeRuntimeMobileSessionTabsResult } from './runtime-mobile-session import type { RuntimeMobileSessionProjectionHost } from './runtime-mobile-session-projection-contract' import { getLatestAgentCandidateTitle, + getLeafDisplayRecord, + getPtyDisplayRecord, terminalTitleBlocksExplicitAgentStatus } from './runtime-worktree-status-projection' @@ -146,16 +148,20 @@ export function projectRuntimeMobileSessionTabs( // null, because persisted ids can collide with an unrelated pane after restart — reading // that pane's tracker would publish its title here, ahead of every other source. const trackerOnlyTitle = host.getTrackedTitle(liveLeafPtyId ?? pty?.ptyId ?? null) - const leafTitle = leaf + const displayLeaf = leaf + ? getLeafDisplayRecord(leaf, host.getTitleDisplayClear(leaf.ptyId)) + : null + const displayPty = pty ? getPtyDisplayRecord(pty, host.getTitleDisplayClear(pty.ptyId)) : null + const leafTitle = displayLeaf ? getLatestAgentCandidateTitle( - { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, - { title: leaf.lastOscTitle, updatedAt: leaf.lastOscTitleAt } + { title: displayLeaf.paneTitle, updatedAt: displayLeaf.paneTitleUpdatedAt }, + { title: displayLeaf.lastOscTitle, updatedAt: displayLeaf.lastOscTitleAt } ) : null - const ptyTitle = pty + const ptyTitle = displayPty ? getLatestAgentCandidateTitle( - { title: pty.title, updatedAt: pty.titleUpdatedAt }, - { title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt } + { title: displayPty.title, updatedAt: displayPty.titleUpdatedAt }, + { title: displayPty.lastOscTitle, updatedAt: displayPty.lastOscTitleAt } ) : null // Renderer omission is authoritative: PTY launch provenance outlives agent exit. @@ -198,7 +204,10 @@ export function projectRuntimeMobileSessionTabs( (hookAgentStatus.providerSessionReceivedAt ?? -1) >= tab.agentStatus.updatedAt) ? hookAgentStatus.providerSession : tab.agentStatus?.providerSession - const statusPty = liveLeafPty ?? mobileStatusPty + const nativeStatusPty = liveLeafPty ?? mobileStatusPty + const statusPty = nativeStatusPty + ? getPtyDisplayRecord(nativeStatusPty, host.getTitleDisplayClear(nativeStatusPty.ptyId)) + : null const normalizedTabAgentStatus = renewRuntimeMobileAgentStatusFromPtyTitle( tab.agentStatus ? normalizeCompatibleAgentStatusEntryForOwner( diff --git a/src/main/runtime/runtime-remote-worktree-create-request.ts b/src/main/runtime/runtime-remote-worktree-create-request.ts index ac40ec64b8d..0f12caff28c 100644 --- a/src/main/runtime/runtime-remote-worktree-create-request.ts +++ b/src/main/runtime/runtime-remote-worktree-create-request.ts @@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron' import type { CreateWorktreeResult } from '../../shared/worktree/create-types' import type { Repo } from '../../shared/repo-types' import { createRemoteWorktree } from '../ipc/worktree-remote' +import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing' import type { Store } from '../persistence' import type { RuntimeStore } from './runtime-store-contract' import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types' @@ -12,6 +13,8 @@ export type RuntimeRemoteWorktreeCreateArgs = Omit< 'repoSelector' > & { startupFollowup?: WorktreeStartupFollowup + /** The create's timing recorder, owned by the runtime's create events. */ + timing?: WorktreeCreateTimingRecorder } export async function requestRuntimeRemoteWorktree( @@ -66,7 +69,8 @@ export async function requestRuntimeRemoteWorktree( }, repo, store as unknown as Store, - headlessWindow + headlessWindow, + args.timing ) if (args.comment !== undefined) { store.setWorktreeMeta(result.worktree.id, { comment: args.comment }) diff --git a/src/main/runtime/runtime-terminal-agent-presence.ts b/src/main/runtime/runtime-terminal-agent-presence.ts index aad71c1a08a..119ce1d8f04 100644 --- a/src/main/runtime/runtime-terminal-agent-presence.ts +++ b/src/main/runtime/runtime-terminal-agent-presence.ts @@ -13,7 +13,10 @@ import { classifyLatestAgentTitle, getLatestAgentCandidateTitle, getLatestLeafTitle, - ptyTitleProvesAgentPresence + getLeafDisplayRecord, + getPtyDisplayRecord, + ptyTitleProvesAgentPresence, + type TitleDisplayClear } from './runtime-worktree-status-projection' const WRAPPER_RETRY_INTERVAL_MS = 150 @@ -28,6 +31,8 @@ type RuntimeTerminalAgentPresenceDependencies = { getTrackedPty(ptyId: string): RuntimePtyWorktreeRecord | null getTabTitle(tabId: string): string | null getForegroundProcess(ptyId: string): Promise | null + /** The stale-working timer's display-only clear of the PTY's own title, if one stands. */ + getTitleDisplayClear(ptyId: string): TitleDisplayClear | null } export type RuntimeTerminalAgentPresenceOptions = { @@ -51,11 +56,24 @@ export class RuntimeTerminalAgentPresence { return true } try { + // Why display records: presence reads what the pane shows, as before the stale-working + // clear stopped rewriting records. A cwd spinner clears to a neutral title, so the + // foreground process decides for an agent that exited behind it. const pty = this.deps.getLivePty(handle) if (pty) { - return await this.isPtyRunning(pty, this.deps.getPrimaryLeaf(pty.ptyId), options) + const clear = this.deps.getTitleDisplayClear(pty.ptyId) + const leaf = this.deps.getPrimaryLeaf(pty.ptyId) + return await this.isPtyRunning( + getPtyDisplayRecord(pty, clear), + leaf ? getLeafDisplayRecord(leaf, clear) : null, + options + ) } - const leaf = this.deps.getLiveLeaf(handle) + const liveLeaf = this.deps.getLiveLeaf(handle) + const leaf = getLeafDisplayRecord( + liveLeaf, + liveLeaf.ptyId ? this.deps.getTitleDisplayClear(liveLeaf.ptyId) : null + ) const trackedPty = leaf.ptyId ? this.deps.getTrackedPty(leaf.ptyId) : null const paneTitle = getLatestLeafTitle(leaf, null) const paneClassification = classifyAgentTitle(paneTitle) diff --git a/src/main/runtime/runtime-terminal-agent-status-query.ts b/src/main/runtime/runtime-terminal-agent-status-query.ts index 5a06ea3f688..cc0c4cf1a62 100644 --- a/src/main/runtime/runtime-terminal-agent-status-query.ts +++ b/src/main/runtime/runtime-terminal-agent-status-query.ts @@ -11,7 +11,11 @@ import type { RuntimePtyController } from './runtime-pty-controller-contract' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' import { terminalTitleBlocksExplicitAgentStatus, - getLatestAgentCandidateTitleInfo + getDisplayPromptLifecycle, + getLatestAgentCandidateTitleInfo, + getLeafDisplayRecord, + getPtyDisplayRecord, + type TitleDisplayClear } from './runtime-worktree-status-projection' import { detectTerminalWaitBlockedReason } from './terminal-wait-detection' import { getTerminalState } from './terminal-wait-results' @@ -38,6 +42,7 @@ type Dependencies = { ptyId: string ): { status: AgentStatus | null; updatedAt: number } | null | undefined isRunning(handle: string): Promise + getTitleDisplayClear(ptyId: string): TitleDisplayClear | null } export class RuntimeTerminalAgentStatusQuery { @@ -63,9 +68,13 @@ export class RuntimeTerminalAgentStatusQuery { private async readStatus(handle: string): Promise { const ptyId = this.getPtyId(handle) - const terminal = this.getSnapshot(handle, ptyId) + // Why display: whether an agent is here is read off what the pane shows, as before the + // stale-working clear stopped rewriting records; a cwd spinner clears to a neutral title, + // so the foreground process decides for an agent that exited behind it. + const clear = this.deps.getTitleDisplayClear(ptyId) + const terminal = this.getSnapshot(handle, ptyId, clear) const explicitStatus = this.deps.getExplicitStatus(handle) - const lifecycle = this.deps.getLifecycleStatus(ptyId) + const lifecycle = getDisplayPromptLifecycle(this.deps.getLifecycleStatus(ptyId), clear) const blockedByWaitText = detectTerminalWaitBlockedReason(terminal.waitText) const liveTitleClearsBlockedText = terminal.titleStatusIsLive && @@ -156,22 +165,20 @@ export class RuntimeTerminalAgentStatusQuery { throw new Error('terminal_handle_stale') } + /** `displayClear` projects the snapshot as display shows it; evidence callers omit it. */ getSnapshot( handle: string, - expectedPtyId: string - ): { - waitText: string - waitBlockedAt: number | null - title: string | null - titleStatus: AgentStatus | null - titleStatusIsLive: boolean - } { - const pty = this.deps.getLivePty(handle) - if (pty) { - if (!pty.pty.connected || pty.pty.ptyId !== expectedPtyId) { + expectedPtyId: string, + displayClear: TitleDisplayClear | null = null + ): RuntimeTerminalAgentStatusSnapshot { + const live = this.deps.getLivePty(handle) + if (live) { + if (!live.pty.connected || live.pty.ptyId !== expectedPtyId) { throw new Error('terminal_not_writable') } - const leaf = this.deps.getPrimaryLeaf(pty.pty.ptyId) + const pty = { pty: getPtyDisplayRecord(live.pty, displayClear) } + const primaryLeaf = this.deps.getPrimaryLeaf(live.pty.ptyId) + const leaf = primaryLeaf ? getLeafDisplayRecord(primaryLeaf, displayClear) : null const leafTitle = leaf ? getLatestAgentCandidateTitleInfo( { title: leaf.paneTitle, updatedAt: leaf.paneTitleUpdatedAt }, @@ -200,7 +207,7 @@ export class RuntimeTerminalAgentStatusQuery { } } - const { leaf } = this.deps.getLiveLeaf(handle) + const leaf = getLeafDisplayRecord(this.deps.getLiveLeaf(handle).leaf, displayClear) if (getTerminalState(leaf) !== 'running') { throw new Error('terminal_exited') } diff --git a/src/main/runtime/runtime-terminal-idle-polls.ts b/src/main/runtime/runtime-terminal-idle-polls.ts index d63b59974ee..c022c3ee661 100644 --- a/src/main/runtime/runtime-terminal-idle-polls.ts +++ b/src/main/runtime/runtime-terminal-idle-polls.ts @@ -11,14 +11,12 @@ import { buildTerminalWaitResult } from './terminal-wait-results' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { evaluateTuiIdle, type QuietForegroundLane, type TuiIdleVerdict } from './tui-idle-evidence' import { - evaluateTuiIdle, leafTuiIdleEvidence, ptyTuiIdleEvidence, - type QuietForegroundLane, - type TuiIdleEvidenceSource, - type TuiIdleVerdict -} from './tui-idle-evidence' + type TuiIdleEvidenceSource +} from './tui-idle-evidence-source' /** * Why null counts as quiet on an `open` lane: a record with no output timestamp has produced diff --git a/src/main/runtime/runtime-terminal-orphan-adoption.ts b/src/main/runtime/runtime-terminal-orphan-adoption.ts index 51e56d0b508..610cc7ae6dd 100644 --- a/src/main/runtime/runtime-terminal-orphan-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-adoption.ts @@ -20,6 +20,8 @@ type RuntimeTerminalOrphanAdoptionPorts = { getPty: (handle: string) => RuntimePtyWorktreeRecord | null getLeaves: (ptyId: string) => readonly RuntimeLeafRecord[] getLeaf: (tabId: string, leafId: string) => RuntimeLeafRecord | undefined + /** The title display surfaces show for the PTY, which an adopted tab persists. */ + getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null /** Replays a binding the session already held: names the pane without claiming the graph holds it. */ replayPersistedSurface: (pty: RuntimePtyWorktreeRecord, tabId: string, paneKey: string) => void /** Names a pane this adoption just wrote, ahead of the graph statement that will carry it. */ @@ -225,7 +227,8 @@ export async function adoptRuntimeTerminalOrphansFromInventory(args: { request, validated, topologyTabsById, - topologyGroups + topologyGroups, + getDisplayTitle: ports.getDisplayTitle }) let staged: WorkspaceSessionState | null = null try { diff --git a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts index e9b2e160d50..106799cf7e4 100644 --- a/src/main/runtime/runtime-terminal-orphan-session-adoption.ts +++ b/src/main/runtime/runtime-terminal-orphan-session-adoption.ts @@ -3,7 +3,6 @@ import type { RuntimeTerminalOrphanAdoptionRequest } from '../../shared/runtime- import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection' import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' -import { getLatestPtyTitle } from './runtime-worktree-status-projection' import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology' import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id' import { advanceTerminalTopologyRevision } from './workspace-session-terminal-membership-authority' @@ -19,6 +18,7 @@ export function buildRuntimeTerminalOrphanSession(args: { validated: readonly { claim: Claim; pty: RuntimePtyWorktreeRecord; paneKey: string }[] topologyTabsById: ReadonlyMap topologyGroups: Topology['groups'] + getDisplayTitle: (pty: RuntimePtyWorktreeRecord) => string | null }): WorkspaceSessionState { const { session, @@ -27,7 +27,8 @@ export function buildRuntimeTerminalOrphanSession(args: { request, validated, topologyTabsById, - topologyGroups + topologyGroups, + getDisplayTitle } = args const next = structuredClone(session) canonicalizeTerminalSessionWorktreeId(next, sessionWorktreeId, worktreeId) @@ -36,7 +37,7 @@ export function buildRuntimeTerminalOrphanSession(args: { for (const { claim, pty, paneKey } of validated) { let tab = tabsById.get(claim.tabId) if (!tab) { - const title = getLatestPtyTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` + const title = getDisplayTitle(pty) ?? pty.controllerTitle ?? `Terminal ${tabsById.size + 1}` tab = { id: claim.tabId, ptyId: claim.ptyId, diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index d3fd9a0d7b3..93e28cf7518 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -87,6 +87,10 @@ export type RuntimePtyWorktreeRecord = RuntimeTerminalTailState & { lastOscTitle: string | null lastOscTitleAt: number | null lastOscTitleEpochMs: number | null + /** The stale-working timer's cleared title, dated as a genuine title would be, while it stands + * over `lastOscTitle`. Display readers project through it (getPtyDisplayRecord); evidence never + * reads it. On the record so it lives as long as the native title it retires. In memory only. */ + titleDisplayClear?: { title: string; observedAt: number; observedAtEpochMs: number } | null managementTitle: string | null managementTitleAt: number | null controllerTitle: string | null diff --git a/src/main/runtime/runtime-terminal-wait.ts b/src/main/runtime/runtime-terminal-wait.ts index 9087d8ad7db..21f56b7be32 100644 --- a/src/main/runtime/runtime-terminal-wait.ts +++ b/src/main/runtime/runtime-terminal-wait.ts @@ -12,13 +12,12 @@ import { import { readsTrustedScreen } from './agent-state-rules/agent-state-rules-engine' import { showsScreenProbeBanner } from './agent-state-rules/agent-state-text-anchors' import { buildTerminalWaitText } from './terminal-wait-tail-state' +import { evaluateTuiIdle, type TuiIdleVerdict } from './tui-idle-evidence' import { - evaluateTuiIdle, leafTuiIdleEvidence, ptyTuiIdleEvidence, - type TuiIdleEvidenceSource, - type TuiIdleVerdict -} from './tui-idle-evidence' + type TuiIdleEvidenceSource +} from './tui-idle-evidence-source' import type { TuiAgent } from '../../shared/tui-agent' import type { TerminalWaiter } from './runtime-terminal-contracts' import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' diff --git a/src/main/runtime/runtime-worktree-ps-activity.ts b/src/main/runtime/runtime-worktree-ps-activity.ts index ae3ecdee4c3..d8d0b6a0109 100644 --- a/src/main/runtime/runtime-worktree-ps-activity.ts +++ b/src/main/runtime/runtime-worktree-ps-activity.ts @@ -8,10 +8,13 @@ import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-term import type { RuntimeWorktreeSummaryPathIndex } from './runtime-worktree-summary-paths' import { getLatestPtyTitle, + getLeafDisplayRecord, getLeafWorktreeStatus, + getPtyDisplayRecord, getSavedTabWorktreeStatus, maxTimestamp, - mergeWorktreeSummaryStatus + mergeWorktreeSummaryStatus, + type TitleDisplayClear } from './runtime-worktree-status-projection' import { runtimeWorktreeIdsEqual } from './runtime-worktree-path-identity' @@ -38,6 +41,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { tabs: ReadonlyMap session: WorkspaceSessionState | null | undefined getPaneKey: (leaf: RuntimeLeafRecord) => string + getTitleDisplayClear: (ptyId: string) => TitleDisplayClear | null getSummary: SummaryLookup }): Map { const workingEvidence = new Map() @@ -87,7 +91,10 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { summary.liveTerminalCount += 1 summary.hasAttachedPty = true summary.lastOutputAt = maxTimestamp(summary.lastOutputAt, leaf.lastOutputAt) - const leafStatus = getLeafWorktreeStatus(leaf, args.tabs.get(leaf.tabId)?.title ?? null) + const leafStatus = getLeafWorktreeStatus( + getLeafDisplayRecord(leaf, args.getTitleDisplayClear(leaf.ptyId)), + args.tabs.get(leaf.tabId)?.title ?? null + ) if (leafStatus === 'working') { addWorkingTerminalEvidence(workingEvidence, summary.worktreeId, { paneKey: args.getPaneKey(leaf), @@ -112,12 +119,15 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { continue } const persistedTabId = savedLayoutTabIdByPtyId.get(pty.ptyId) + const displayTitle = getLatestPtyTitle( + getPtyDisplayRecord(pty, args.getTitleDisplayClear(pty.ptyId)) + ) let owner = persistedTabId ? savedTabOwnerById.get(persistedTabId) : undefined if (args.freshPtyLiveness !== null) { - owner = { worktreeId: pty.worktreeId, title: owner?.title ?? getLatestPtyTitle(pty) ?? '' } + owner = { worktreeId: pty.worktreeId, title: owner?.title ?? displayTitle ?? '' } } if (!owner && persistedTabId && pty.tabId === persistedTabId) { - owner = { worktreeId: pty.worktreeId, title: getLatestPtyTitle(pty) ?? '' } + owner = { worktreeId: pty.worktreeId, title: displayTitle ?? '' } } const pane = parsePaneKey(pty.paneKey ?? '') const hasExplicitOwner = @@ -128,7 +138,7 @@ export function applyRuntimeWorktreePsTerminalActivity(args: { if (!owner && hasExplicitOwner && !hasSavedLayout) { owner = { worktreeId: savedOwner?.worktreeId ?? pty.worktreeId, - title: savedOwner?.title ?? getLatestPtyTitle(pty) ?? '' + title: savedOwner?.title ?? displayTitle ?? '' } } if (!owner) { diff --git a/src/main/runtime/runtime-worktree-status-projection.ts b/src/main/runtime/runtime-worktree-status-projection.ts index 7e8244a8836..fce985b3d94 100644 --- a/src/main/runtime/runtime-worktree-status-projection.ts +++ b/src/main/runtime/runtime-worktree-status-projection.ts @@ -39,6 +39,8 @@ type PtyTitleRecord = { lastOscTitleAt: number | null } +type TitleCandidate = { title: string | null | undefined; updatedAt: number | null | undefined } + type PtyAgentPresenceRecord = { launchAgent: TuiAgent | null launchToken: string | null @@ -46,6 +48,78 @@ type PtyAgentPresenceRecord = { incarnationId: PtyIncarnationId | null } +/** The stale-working timer's display-only clear of a PTY's native title, while it stands. */ +export type TitleDisplayClear = { + title: string + status: AgentStatus | null + /** Dated as a genuine title would be: observation sequence and wall clock. */ + observedAt: number + observedAtEpochMs: number +} + +type PtyTitleEvidence = PtyTitleRecord & { + lastOscTitleEpochMs: number | null + lastAgentStatus: AgentStatus | null + lastAgentStatusStartedAtEpochMs: number | null +} + +/** + * A PTY record as display surfaces show it. Records keep the agent's own title for readiness, + * delivery and presence; display readers project through here so they show the cleared title + * the tab shows. Why max(): a restore seed re-stamps the native title after the clear. + */ +export function getPtyDisplayRecord( + pty: T, + clear: TitleDisplayClear | null +): T { + if (!clear) { + return pty + } + return { + ...pty, + lastOscTitle: clear.title, + lastOscTitleAt: Math.max(pty.lastOscTitleAt ?? 0, clear.observedAt), + lastOscTitleEpochMs: clear.observedAtEpochMs, + lastAgentStatus: clear.status, + lastAgentStatusStartedAtEpochMs: + clear.status === pty.lastAgentStatus + ? pty.lastAgentStatusStartedAtEpochMs + : clear.observedAtEpochMs + } +} + +/** The leaf counterpart of {@link getPtyDisplayRecord}; `clear` is its PTY's. */ +export function getLeafDisplayRecord( + leaf: T, + clear: TitleDisplayClear | null +): T { + if (!clear) { + return leaf + } + return { + ...leaf, + lastOscTitle: clear.title, + lastOscTitleAt: Math.max(leaf.lastOscTitleAt ?? 0, clear.observedAt), + lastAgentStatus: clear.status + } +} + +/** + * The prompt lifecycle as display shows it. Main recorded the clear's status there as well, so a + * title snapshot projected through the clear is only comparable with a lifecycle projected the + * same way. Display, blocked-dialog checks and prompt-submission verification read it; readiness + * and delivery never do. + */ +export function getDisplayPromptLifecycle( + lifecycle: { status: AgentStatus | null; updatedAt: number } | null | undefined, + clear: TitleDisplayClear | null +): { status: AgentStatus | null; updatedAt: number } | null | undefined { + if (!clear || (lifecycle && lifecycle.updatedAt > clear.observedAtEpochMs)) { + return lifecycle + } + return { status: clear.status, updatedAt: clear.observedAtEpochMs } +} + export function getLeafWorktreeStatus( leaf: LeafStatusRecord, tabTitle: string | null @@ -62,7 +136,7 @@ export function getLeafWorktreeStatus( } export function classifyLatestAgentTitle( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] + ...titles: TitleCandidate[] ): 'agent' | 'management' | 'neutral' { return classifyAgentTitle(getLatestAgentCandidateTitle(...titles)) } @@ -132,24 +206,27 @@ export function terminalTitleBlocksExplicitAgentStatus(title: string | null): bo return isClaudeManagementTitle(title) || isShellProcess(title) } -export function getLatestAgentCandidateTitle( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] -): string | null { +export function getLatestAgentCandidateTitle(...titles: TitleCandidate[]): string | null { return getLatestAgentCandidateTitleInfo(...titles)?.title ?? null } export function getLatestAgentCandidateTitleInfo( - ...titles: { title: string | null | undefined; updatedAt: number | null | undefined }[] + ...titles: TitleCandidate[] ): { title: string; updatedAt: number } | null { - let latest: { title: string; updatedAt: number } | null = null - for (const candidate of titles) { - const title = candidate.title?.trim() - if (!title) { + const latest = getLatestAgentCandidate(...titles) + const title = latest?.title?.trim() + return latest && title ? { title, updatedAt: latest.updatedAt ?? 0 } : null +} + +/** The newest non-blank candidate itself, so a caller can tell which source won. */ +export function getLatestAgentCandidate(...candidates: T[]): T | null { + let latest: T | null = null + for (const candidate of candidates) { + if (!candidate.title?.trim()) { continue } - const updatedAt = candidate.updatedAt ?? 0 - if (!latest || updatedAt > latest.updatedAt) { - latest = { title, updatedAt } + if (!latest || (candidate.updatedAt ?? 0) > (latest.updatedAt ?? 0)) { + latest = candidate } } return latest diff --git a/src/main/runtime/screen-input-veto.ts b/src/main/runtime/screen-input-veto.ts new file mode 100644 index 00000000000..040d8ec255c --- /dev/null +++ b/src/main/runtime/screen-input-veto.ts @@ -0,0 +1,23 @@ +import type { TuiAgent } from '../../shared/tui-agent' +import { isOmpOverlayScreen } from './omp-terminal-readiness' + +/** An agent's grid as painted on its own PTY, and whether that is the alternate screen. */ +export type RuledScreen = { lines: readonly string[]; alternateScreen: boolean } + +/** + * Agents whose screen can refuse input whatever the other evidence says. OMP paints its idle + * title before its setup wizard opens, so a title, hook or quiet lane alone would type into it. + */ +const SCREEN_INPUT_VETOES: Partial boolean>> = { + omp: isOmpOverlayScreen +} + +/** Whether the agent's live screen refuses input; null with no veto rule or no trustworthy screen. */ +export function readScreenInputVeto( + agent: TuiAgent | null | undefined, + readScreen: () => RuledScreen | null +): boolean | null { + const veto = agent ? SCREEN_INPUT_VETOES[agent] : undefined + const screen = veto ? readScreen() : null + return veto && screen ? veto(screen) : null +} diff --git a/src/main/runtime/stale-title-completion-recovery.test.ts b/src/main/runtime/stale-title-completion-recovery.test.ts new file mode 100644 index 00000000000..517d032e350 --- /dev/null +++ b/src/main/runtime/stale-title-completion-recovery.test.ts @@ -0,0 +1,70 @@ +import { rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' +import { settledWriteStub } from '../providers/settled-pty-write-stub' +import { + createBoundRun, + createDatabase, + createRuntime, + insertDirectRunMessage, + PANE_KEY, + PTY_ID, + TERMINAL_HANDLE, + temporaryDirectories +} from './orchestration-mailbox-notification-test-harness' + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => tmpdir()), isPackaged: false }, + BrowserWindow: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + webContents: { fromId: vi.fn(() => null) } +})) + +describe('stale-working title clear and completion recovery', () => { + afterEach(() => { + vi.useRealTimers() + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + // Synthetic: Codex's done hook, then a final native spinner that only the timer clears (#19243). + it('delivers mail once the clear confirms a live Codex behind a fresh done hook', async () => { + vi.useFakeTimers() + const db = createDatabase('orca-stale-title-completion-') + const hook: AgentStatusIpcPayload = { + paneKey: PANE_KEY, + terminalHandle: TERMINAL_HANDLE, + agentType: 'codex', + state: 'done', + prompt: '', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now() + } + const { runtime } = createRuntime(db, { getAgentStatusSnapshot: () => [hook] }) + const write = vi.fn((_ptyId: string, _data: string) => true) + runtime.setPtyController({ + write, + writeWithSettlement: settledWriteStub(write), + kill: vi.fn(), + getForegroundProcess: vi.fn(async () => 'codex') + }) + const run = createBoundRun(db, 'Timer Run') + await runtime.listTerminals() + runtime.ingestSyntheticTitleFrame(PTY_ID, '\x1b]0;Codex ready\x07') + runtime.onPtyData(PTY_ID, '\x1b]0;⠋ mobile-rearch\x07', 1) + runtime.onPtyData(PTY_ID, 'composer repaint without a title', 2) + insertDirectRunMessage(db, run.id, 'Worker progress') + runtime.notifyMessageArrived(`run:${run.id}`, 'status') + await vi.advanceTimersByTimeAsync(1_000) + expect(write).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(4_000) + expect(write.mock.calls.map(([, data]) => data)).toEqual([ + expect.stringContaining('You have 1 orchestration message'), + '\r' + ]) + db.close() + }) +}) diff --git a/src/main/runtime/stale-title-display-clear.test.ts b/src/main/runtime/stale-title-display-clear.test.ts new file mode 100644 index 00000000000..3a5c169573c --- /dev/null +++ b/src/main/runtime/stale-title-display-clear.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { createTranscriptPane, TRANSCRIPT_PANE_PTY_ID } from './agent-transcript-pane-test-harness' +import { clearWorkingIndicators } from '../../shared/agent-title-status' + +vi.mock('electron', () => ({ + BrowserWindow: { fromId: vi.fn(() => null) }, + webContents: { fromId: vi.fn(() => null) }, + ipcMain: { on: vi.fn(), removeListener: vi.fn() }, + app: { getPath: vi.fn(() => '/tmp') } +})) + +// Synthetic protocol cases: they test which title readiness, display and presence each read, +// not what a particular CLI paints. The 3 s timer is the stale-working-title clear. +describe('the stale-working title clear is display-only', () => { + it.each([ + ['claude', '. claude', '* claude'], + ['codex', '⠋ Codex working', 'Codex ready'], + ['gemini', '✦ Gemini CLI', '◇ Gemini CLI'], + ['opencode', '⠋ OC | task', 'OC | task'], + ['pi', 'π : project', 'π - project'] + ] as const)( + '%s does not become ready when its display title clears', + async (agent, busy, idle) => { + vi.useFakeTimers() + try { + const titleChanges: string[] = [] + const { runtime, handle } = await createTranscriptPane( + { + paneTitle: busy, + foregroundProcess: agent, + data: '', + launchAgent: agent + }, + { + onTerminalSideEffects: (batch) => { + for (const fact of batch.facts) { + if (fact.kind === 'title') { + titleChanges.push(fact.normalizedTitle) + } + } + } + } + ) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${busy}\x07`, Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + expect(titleChanges.at(-1)).toBe(clearWorkingIndicators(busy)) + // A renderer graph publish can carry the display-only cleared title back to main. + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + title: clearWorkingIndicators(busy), + activeLeafId: '11111111-1111-4111-8111-111111111111', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-1', + worktreeId: 'wt-1', + leafId: '11111111-1111-4111-8111-111111111111', + paneRuntimeId: 1, + ptyId: TRANSCRIPT_PANE_PTY_ID, + paneTitle: clearWorkingIndicators(busy) + } + ] + }) + const waiting = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 200 }) + const assertion = expect(waiting).rejects.toThrow('timeout') + await Promise.all([assertion, vi.advanceTimersByTimeAsync(200)]) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${idle}\x07`, Date.now()) + const ready = runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 2_000 }) + const readyAssertion = expect(ready).resolves.toMatchObject({ satisfied: true }) + await Promise.all([readyAssertion, vi.advanceTimersByTimeAsync(2_000)]) + } finally { + vi.useRealTimers() + } + } + ) + + // A name-shaped title (`⠋ Codex working`) clears to `Codex`, which still names an agent, as on main. + it.each([['⠋ repo']])( + 'an agent that exited behind %s does not pass for a running agent', + async (title) => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'zsh', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, `\x1b]0;${title}\x07`, Date.now()) + // The agent exits and the shell prints its prompt: output, but no title. + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + } + ) + + it('a live agent behind a cleared title is still found by its process', async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: 'codex', + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it("the agent's next genuine title proves presence again", async () => { + vi.useFakeTimers() + try { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ repo\x07', Date.now()) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠙ repo\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + } finally { + vi.useRealTimers() + } + }) + + it('a live Cursor spinner title proves presence when no foreground read can answer', async () => { + const { runtime, handle } = await createTranscriptPane({ + paneTitle: 'Terminal', + foregroundProcess: null, + data: '' + }) + runtime.onPtyData(TRANSCRIPT_PANE_PTY_ID, '\x1b]0;⠋ Cursor Agent\x07', Date.now()) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'working' + }) + }) +}) diff --git a/src/main/runtime/stale-title-display-surfaces.test.ts b/src/main/runtime/stale-title-display-surfaces.test.ts new file mode 100644 index 00000000000..668f0efe35d --- /dev/null +++ b/src/main/runtime/stale-title-display-surfaces.test.ts @@ -0,0 +1,376 @@ +import { describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec' +import './orca-runtime-test-lifecycle.spec' +import { assertTerminalAgentSendable } from './rpc/terminal-agent-send-guard' +import { + HEADLESS_LEAF_ID, + TEST_WORKTREE_ID, + store, + syncSinglePty +} from './orca-runtime-test-fixtures.spec' + +async function psStatus(runtime: OrcaRuntimeService): Promise { + return (await runtime.getWorktreePs()).worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + ?.status +} + +// Synthetic titles: display surfaces must show what the tab shows once the stale-working timer +// clears a title, although the runtime keeps the agent's own title as evidence. +describe('display surfaces after the stale-working title clear', () => { + it('worktree ps and the phone show the cleared status for a terminal with no renderer pane', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'bg-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [], + leaves: [], + mobileSessionTabs: [ + { + worktree: TEST_WORKTREE_ID, + publicationEpoch: 'renderer-empty', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + ] + }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + command: 'codex', + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + const psStatus = async () => + (await runtime.getWorktreePs()).worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + ?.status + const phoneTabs = async () => + (await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.map((tab) => ({ + title: 'title' in tab ? tab.title : undefined, + state: 'agentStatus' in tab ? tab.agentStatus?.state : undefined + })) + runtime.onPtyData('bg-pty', '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData('bg-pty', 'output without a title\r\n', Date.now()) + expect(await psStatus()).toBe('working') + expect(await phoneTabs()).toEqual([{ title: 'Codex working', state: 'working' }]) + + await vi.advanceTimersByTimeAsync(3_000) + + expect(await psStatus()).toBe('active') + expect(await phoneTabs()).toEqual([{ title: 'Codex', state: 'done' }]) + const listed = (await runtime.listTerminals()).terminals.find( + (candidate) => candidate.handle === terminal.handle + ) + expect(listed?.title).toBe('Codex') + + // The agent's next genuine title retires the clear. + runtime.onPtyData('bg-pty', '\x1b]0;Codex working\x07', Date.now()) + expect(await psStatus()).toBe('working') + expect(await phoneTabs()).toEqual([{ title: 'Codex working', state: 'working' }]) + } finally { + vi.useRealTimers() + } + }) + + it('a reattach restore seed does not bring the cleared working status back', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'codex' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'Codex working' }) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData('pty-1', 'output without a title\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + // The renderer applies the cleared fact and republishes its pane title. + syncSinglePty(runtime, 'pty-1', { paneTitle: 'Codex' }) + const psStatus = async () => + (await runtime.getWorktreePs()).worktrees.find((w) => w.worktreeId === TEST_WORKTREE_ID) + ?.status + expect(await psStatus()).toBe('active') + // A renderer reload reattaches with the daemon's last title, as the spawn RPC path does. + runtime.seedTerminalRestoreTail('pty-1', { lastTitle: 'Codex working' }) + syncSinglePty(runtime, 'pty-1', { paneTitle: 'Codex' }) + expect(await psStatus()).toBe('active') + } finally { + vi.useRealTimers() + } + }) + + it('an SSH relay drop and same-incarnation reattach keep the cleared display', async () => { + vi.useFakeTimers() + try { + const sshPtyId = 'ssh:conn-1@@relay-9' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const register = () => + runtime.registerPty(sshPtyId, TEST_WORKTREE_ID, 'conn-1', { + tabId: 'tab-1', + leafId: HEADLESS_LEAF_ID, + incarnationId: 'inc-1' + }) + register() + runtime.onPtyData(sshPtyId, '\x1b]0;Codex working\x07', Date.now()) + runtime.onPtyData(sshPtyId, 'output without a title\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + expect(await psStatus(runtime)).toBe('active') + // An abnormal relay exit keeps the record for the reconnect grace; the relay reattaches. + runtime.onPtyExit(sshPtyId, -1) + register() + await vi.advanceTimersByTimeAsync(60_000) + expect(await psStatus(runtime)).toBe('active') + expect((await runtime.listTerminals()).terminals.map((t) => t.title)).toEqual(['Codex']) + } finally { + vi.useRealTimers() + } + }) + + it.each(['⠋ Codex working', '⠋ repo'])( + 'an agent that exits behind %s loses its foreground identity', + async (spinner) => { + vi.useFakeTimers() + try { + let foreground: string | null = 'codex' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => foreground + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: null, tabTitle: 'Terminal' }) + runtime.onPtyData('pty-1', '\x1b]0;Codex ready\x07', Date.now()) + runtime.onPtyData('pty-1', `\x1b]0;${spinner}\x07`, Date.now()) + await vi.advanceTimersByTimeAsync(100) + const identity = async () => + (await runtime.listTerminals()).terminals[0]?.agentIdentity ?? null + expect(await identity()).not.toBeNull() + foreground = 'zsh' + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_500) + expect(await identity()).toBeNull() + } finally { + vi.useRealTimers() + } + } + ) + + it('a renderer pane title older than the clear cannot prove presence', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'zsh' + }) + syncSinglePty(runtime, 'pty-1', { paneTitle: null }) + runtime.onPtyData('pty-1', '\x1b]0;⠋ repo\x07', Date.now()) + // The renderer republishes the spinner after main recorded it, and has not echoed the clear. + syncSinglePty(runtime, 'pty-1', { paneTitle: '⠋ repo' }) + runtime.onPtyData('pty-1', '\x1b]133;D;0\x07\x1b]133;A\x07% ', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + const handle = (await runtime.listTerminals()).terminals[0]?.handle ?? '' + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: false, + status: null + }) + await expect( + runtime.isTerminalRunningAgent(handle, { retryForegroundWrappers: false }) + ).resolves.toBe(false) + } finally { + vi.useRealTimers() + } + }) + + it('a live agent with an unrecognized process keeps presence through its cleared name title', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'bg-pty' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'Python' + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const terminal = await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + runtime.onPtyData('bg-pty', '\x1b]0;⠋ Claude Code\x07', Date.now()) + runtime.onPtyData('bg-pty', 'output without a title\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + // Main read the cleared `Claude Code`, which names an agent, so a live wrapper-run agent stays found. + await expect(runtime.getTerminalAgentStatus(terminal.handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'idle' + }) + await expect( + runtime.isTerminalRunningAgent(terminal.handle, { retryForegroundWrappers: false }) + ).resolves.toBe(true) + } finally { + vi.useRealTimers() + } + }) + + // Synthetic: a trust dialog painted after the clear, behind the name title `⠋ Claude Code`. + it.each(['mounted', 'mountedEcho', 'rendererless'] as const)( + 'a trust dialog painted after the clear still reads as a wait on the user (%s)', + async (mode) => { + vi.useFakeTimers() + try { + const ptyId = mode === 'rendererless' ? 'bg-pty' : 'pty-1' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: ptyId }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + let handle: string + if (mode === 'rendererless') { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + handle = ( + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + ).handle + } else { + syncSinglePty(runtime, ptyId, { tabTitle: 'Terminal 1', paneTitle: null }) + runtime.onPtyData(ptyId, 'boot\r\n', Date.now()) + handle = (await runtime.listTerminals()).terminals[0]?.handle ?? '' + } + runtime.onPtyData(ptyId, '\x1b]0;⠋ Claude Code\x07', Date.now()) + if (mode !== 'rendererless') { + syncSinglePty(runtime, ptyId, { tabTitle: '⠋ Claude Code', paneTitle: '⠋ Claude Code' }) + } + runtime.onPtyData(ptyId, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + if (mode === 'mountedEcho') { + // The renderer republishes its own cleared title, as a mounted pane does. + syncSinglePty(runtime, ptyId, { tabTitle: 'Claude Code', paneTitle: 'Claude Code' }) + } + await vi.advanceTimersByTimeAsync(1_000) + runtime.onPtyData( + ptyId, + '\r\nDo you trust the files in this folder?\r\n /repo/app\r\n 1. Yes, proceed\r\n 2. No, exit\r\n', + Date.now() + ) + await vi.advanceTimersByTimeAsync(1_000) + await expect(runtime.getTerminalAgentStatus(handle)).resolves.toMatchObject({ + isRunningAgent: true, + status: 'permission' + }) + await expect( + assertTerminalAgentSendable({ runtime, handle, assertWritable: () => {} }) + ).rejects.toThrow('terminal_guard_permission') + await expect(runtime.getTerminalInteractiveWait(handle)).resolves.toMatchObject({ + source: 'prompt-text', + reason: 'agent-trust-workspace' + }) + } finally { + vi.useRealTimers() + } + } + ) + + // Synthetic: prompt verification behind a standing clear judges against the baseline main had. + it.each([ + ['mounted', 'output only', false], + ['mountedEcho', 'output only', false], + ['rendererless', 'output only', false], + ['mounted', 'a new spinner frame', true], + ['mountedEcho', 'a new spinner frame', true], + ['rendererless', 'a new spinner frame', true] + ] as const)( + 'a prompt sent behind a cleared spinner (%s) answered by %s settles as on main', + async (mode, _after, spinnerFrame) => { + vi.useFakeTimers() + try { + const ptyId = mode === 'rendererless' ? 'bg-pty' : 'pty-1' + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: ptyId }), + write: (_ptyId, data) => { + if (data === '\r') { + runtime.onPtyData( + ptyId, + spinnerFrame ? '\x1b]0;⠙ Claude Code\x07' : '\r\n', + Date.now() + ) + } + return true + }, + kill: () => true, + getForegroundProcess: async () => 'claude' + }) + let handle: string + if (mode === 'rendererless') { + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + handle = ( + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'bg-tab', + leafId: HEADLESS_LEAF_ID + }) + ).handle + } else { + syncSinglePty(runtime, ptyId, { tabTitle: 'Terminal 1', paneTitle: null }) + runtime.onPtyData(ptyId, 'boot\r\n', Date.now()) + handle = (await runtime.listTerminals()).terminals[0]?.handle ?? '' + } + runtime.onPtyData(ptyId, '\x1b]0;⠋ Claude Code\x07', Date.now()) + if (mode !== 'rendererless') { + syncSinglePty(runtime, ptyId, { tabTitle: '⠋ Claude Code', paneTitle: '⠋ Claude Code' }) + } + runtime.onPtyData(ptyId, 'still running\r\n', Date.now()) + await vi.advanceTimersByTimeAsync(3_000) + if (mode === 'mountedEcho') { + syncSinglePty(runtime, ptyId, { tabTitle: 'Claude Code', paneTitle: 'Claude Code' }) + } + if (spinnerFrame) { + // The receipt path takes no output as evidence, so only a new turn settles it. + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving', + acceptQueued: true, + requestId: `after-clear-${mode}` + }) + await vi.advanceTimersByTimeAsync(20_000) + await expect(submission).resolves.toMatchObject({ + prompt: { stages: ['input_accepted', 'turn_started'] } + }) + } else { + // Output after Enter is not a turn start, so a swallowed Enter still reads as stalled. + const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', { + inputKind: 'driving' + }) + const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled') + // Past the 30 s effect window. + await vi.advanceTimersByTimeAsync(40_000) + await rejected + } + } finally { + vi.useRealTimers() + } + } + ) +}) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index c9c3b8448ad..49c694b5adb 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -34,6 +34,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' import { attachParams, fakeCodex, @@ -56,6 +57,7 @@ let db: OrchestrationDb let host: StructuredAgentSessionHost let dispatcher: RpcDispatcher let requests = 0 +const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR) function request( method: string, @@ -284,10 +286,15 @@ function startRuntime(): OrcaRuntimeService { } afterEach(async () => { - await stopStructuredAgentSessionRuntime() - db.close() - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) + try { + await stopStructuredAgentSessionRuntime() + db.close() + await observationClock.drainClosedDatabaseRepair() + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) + } finally { + observationClock.restore() + } }) // Pointers are sent on asynchronous edges; the default 1s wait is too tight under a loaded parallel run. @@ -350,15 +357,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) /** Fires both edges and waits until every gate read they started has answered. */ - async function edgesAnswered(): Promise { - const reads = vi.spyOn(host, 'journalSnapshot') - runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: null }) - runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) - await vi.waitFor(() => expect(reads).toHaveBeenCalled(), WAIT) - await Promise.all(reads.mock.results.map((read) => read.value)) - await new Promise((resolve) => setImmediate(resolve)) - reads.mockRestore() - } + const edgesAnswered = (): Promise => observationClock.edgesAnswered(runtime, WAIT) /** The operation ids the coordinator's journal recorded for its pointer turns. */ async function pointerSends(): Promise { @@ -407,6 +406,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) it('does not restart a provider that dies before every echo, however many edges follow', async () => { + observationClock.start() // What this pins: each death's own status edge used to re-point the mail, and that send started // the provider again, about once a second for as long as the mail was unread. await openChat(COORDINATOR) @@ -416,7 +416,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = await finishWorker(taskId) await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) // A fixed window, not a poll: a respawn loop would restart it several times in it. - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) await edgesAnswered() expect(codex.connections.length - before).toBe(0) expect(providerFaults.turnStarts).toBe(1) @@ -426,6 +426,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = it.each(['exit-then-throw', 'throw-then-exit'] as const)( 'does not restart a provider that crashed while taking the pointer turn (%s)', async (crash) => { + observationClock.start() // The crash settles the send `unknown` with the connection's own error, not as a provider // exit; every status edge after it re-pointed the mail and started the provider again. await openChat(COORDINATOR) @@ -434,7 +435,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = const before = providerFaults.starts await finishWorker(taskId) await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(1), WAIT) - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) await edgesAnswered() expect(providerFaults.starts - before).toBe(0) expect(providerFaults.turnStarts).toBe(1) @@ -501,7 +502,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = expect(cancelled).toMatchObject({ ok: true }) providerFaults.startDelayMs = 0 // A fixed window, not a poll: a re-point would start the agent again in it. - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) expect(providerFaults.starts - before).toBe(1) expect(await pointerSends()).toHaveLength(1) await edgesAnswered() @@ -511,6 +512,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = }) it('points a held pointer once more after Orca restarts, under a new id', async () => { + observationClock.start() await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() providerFaults.dieBeforeEveryEcho = true @@ -525,7 +527,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) const before = providerFaults.starts await vi.waitFor(() => expect(providerFaults.turnStarts).toBe(2), WAIT) - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) await edgesAnswered() expect(providerFaults.starts - before).toBe(1) expect(providerFaults.turnStarts).toBe(2) @@ -539,6 +541,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = async function refusedStartsFor( refusal: () => Error ): Promise<{ runId: string; starts: number }> { + observationClock.start() await openChat(COORDINATOR) const { runId, taskId } = await coordinatorRunAndTask() await host.close(COORDINATOR, 'evict') @@ -551,7 +554,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = WAIT ) // A fixed window, not a poll: a retry loop would start it several times in it. - await new Promise((resolve) => setTimeout(resolve, 1_500)) + await observationClock.observe(1_500) return { runId, starts: providerFaults.starts - before } } @@ -576,7 +579,7 @@ describe('a worker result reaches the structured chat that coordinates it', () = const before = providerFaults.starts for (let edge = 0; edge < 5; edge += 1) { runtime.onStructuredSessionStatusForMail({ sessionId: COORDINATOR, status: 'idle' }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await observationClock.observe(100) } await edgesAnswered() expect(providerFaults.starts).toBe(before) diff --git a/src/main/runtime/structured-chat-coordinator-observation-clock.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-observation-clock.test-fixture.ts new file mode 100644 index 00000000000..cced136ce8b --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-observation-clock.test-fixture.ts @@ -0,0 +1,55 @@ +import { expect, vi } from 'vitest' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { OrcaRuntimeService } from './orca-runtime' +import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' + +export function createCoordinatorMailObservationClock( + getHost: () => StructuredAgentSessionHost, + sessionId: string +) { + let active = false + return { + start(): void { + vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] }) + active = true + }, + async observe(ms: number): Promise { + if (!active) { + await new Promise((resolve) => setTimeout(resolve, ms)) + return + } + await vi.advanceTimersByTimeAsync(ms) + await waitForStructuredAgentSessionRecovery() + await getHost().flushStreamedEvents(sessionId) + await new Promise((resolve) => setImmediate(resolve)) + }, + async edgesAnswered( + runtime: Pick, + wait: { timeout: number } + ): Promise { + const reads = vi.spyOn(getHost(), 'journalSnapshot') + runtime.onStructuredSessionStatusForMail({ sessionId, status: null }) + runtime.onStructuredSessionStatusForMail({ sessionId, status: 'idle' }) + await vi.waitFor(() => expect(reads).toHaveBeenCalled(), wait) + await Promise.all(reads.mock.results.map((read) => read.value)) + await new Promise((resolve) => setImmediate(resolve)) + reads.mockRestore() + }, + async drainClosedDatabaseRepair(): Promise { + if (!active) { + return + } + // The runtime's orphan mailbox repair must still run against the closed database. + await vi.advanceTimersByTimeAsync(2_000) + await waitForStructuredAgentSessionRecovery() + await new Promise((resolve) => setImmediate(resolve)) + expect(vi.getTimerCount()).toBe(0) + }, + restore(): void { + if (active) { + vi.useRealTimers() + } + active = false + } + } +} diff --git a/src/main/runtime/structured-worker-agent-presence.test.ts b/src/main/runtime/structured-worker-agent-presence.test.ts index ca31e891cc9..d500ae5e1cc 100644 --- a/src/main/runtime/structured-worker-agent-presence.test.ts +++ b/src/main/runtime/structured-worker-agent-presence.test.ts @@ -25,7 +25,8 @@ function presence(isLiveStructuredAgent: (handle: string) => boolean) { getPrimaryLeaf: () => null, getTrackedPty: () => null, getTabTitle: () => null, - getForegroundProcess: () => null + getForegroundProcess: () => null, + getTitleDisplayClear: () => null }) } } diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts index b4c1c888012..273feb94311 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.test.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -8,22 +8,22 @@ import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { computeTerminalTailWaitState } from './terminal-wait-tail-state' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' // The definition the incremental index must reproduce: does ANY retained line (or the // partial line) match the sentinel? Written out independently of the implementation. function referenceMayContainBlockedSignal(lines: string[], partialLine: string): boolean { for (const line of lines) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + if (terminalWaitBlockedSentinelRe().test(line)) { return true } } - return TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + return terminalWaitBlockedSentinelRe().test(partialLine) } function indexedMayContainBlockedSignal(lines: string[], partialLine: string): boolean { - return tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + return tailMayContainBlockedSignal(lines) || terminalWaitBlockedSentinelRe().test(partialLine) } type TailSim = { @@ -66,7 +66,7 @@ const ESC = String.fromCharCode(27) function referenceSentinelMatches(lines: readonly string[]): number[] { const matches: number[] = [] for (let index = 0; index < lines.length; index += 1) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + if (terminalWaitBlockedSentinelRe().test(lines[index]!)) { matches.push(index) } } @@ -88,7 +88,7 @@ function assertIndexedPositionsAreExact(lines: readonly string[]): void { } function countSentinelTests(run: () => void): number { - const spy = vi.spyOn(TERMINAL_WAIT_BLOCKED_SENTINEL_RE, 'test') + const spy = vi.spyOn(terminalWaitBlockedSentinelRe(), 'test') try { run() return spy.mock.calls.length diff --git a/src/main/runtime/terminal-tail-sentinel-index.ts b/src/main/runtime/terminal-tail-sentinel-index.ts index 799bf759696..df9b358a847 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.ts @@ -1,4 +1,4 @@ -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' /** * Which retained tail lines match the wait-blocked sentinel, memoized per @@ -9,17 +9,22 @@ import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-t * per streaming PTY) even though only ~20 lines were new. Keyed weakly by the * array so an entry dies with the tail it describes; the tail array is replaced * on every append and never mutated in place, so at most one entry per PTY - * stays live. + * stays live. Each entry keeps the sentinel it was scanned with: a rules hot reload swaps it, and + * matches found by the old one would hide a blocker only the new one knows. */ -const sentinelMatchesByTailLines = new WeakMap() +const sentinelMatchesByTailLines = new WeakMap< + readonly string[], + { sentinel: RegExp; matches: number[] } +>() function collectSentinelMatches( + sentinel: RegExp, lines: readonly string[], startIndex: number, into: number[] ): void { for (let index = startIndex; index < lines.length; index += 1) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + if (sentinel.test(lines[index]!)) { into.push(index) } } @@ -40,14 +45,15 @@ export function getTerminalTailSentinelFullScanCount(): number { /** Ascending indices of sentinel-matching lines; full-scans an unseen array. */ export function getTerminalTailSentinelMatches(lines: readonly string[]): readonly number[] { + const sentinel = terminalWaitBlockedSentinelRe() const cached = sentinelMatchesByTailLines.get(lines) - if (cached) { - return cached + if (cached?.sentinel === sentinel) { + return cached.matches } sentinelFullScanCount += 1 const matches: number[] = [] - collectSentinelMatches(lines, 0, matches) - sentinelMatchesByTailLines.set(lines, matches) + collectSentinelMatches(sentinel, lines, 0, matches) + sentinelMatchesByTailLines.set(lines, { sentinel, matches }) return matches } @@ -89,6 +95,7 @@ export function carryTerminalTailSentinelMatches( } } } - collectSentinelMatches(nextLines, carriedCount, matches) - sentinelMatchesByTailLines.set(nextLines, matches) + const sentinel = terminalWaitBlockedSentinelRe() + collectSentinelMatches(sentinel, nextLines, carriedCount, matches) + sentinelMatchesByTailLines.set(nextLines, { sentinel, matches }) } diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index fb24d21c124..a2e6ee3f073 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -11,6 +11,7 @@ import { } from './agent-state-rules/agent-state-rules-engine' import { findPromptAnchorIndexes } from './agent-state-rules/agent-state-text-anchors' import { showsIdleTitleAnchor } from './agent-state-rules/agent-state-title-anchors' +import { compiledFromActiveAgentStateRules } from './agent-state-rules/active-agent-state-rules' import { findTerminalWaitBlockedSignal, isSettledAfter, @@ -29,12 +30,18 @@ function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { } /** - * Pure in `title`, so it is memoized on the title string like the status classifier it - * wraps: the wait path re-asks for the same unchanged title on every poll tick and every - * repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). + * Pure in `title` for one rule set, so it is memoized on the title string like the status + * classifier it wraps: the wait path re-asks for the same unchanged title on every poll tick and + * every repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). Why a + * fresh memo per rule set: a rules reload can change which titles read as idle. */ -export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | null = +const explicitIdleTitleMemo = compiledFromActiveAgentStateRules(() => memoizeTitleClassification(computeExplicitIdleStatusFromTitle) +) + +export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { + return explicitIdleTitleMemo()(title) +} export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() diff --git a/src/main/runtime/terminal-wait-tail-state.ts b/src/main/runtime/terminal-wait-tail-state.ts index 485451abf0c..28bb094b8e3 100644 --- a/src/main/runtime/terminal-wait-tail-state.ts +++ b/src/main/runtime/terminal-wait-tail-state.ts @@ -2,7 +2,7 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type import { buildTailLines } from './terminal-tail-state' import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { findActionableTerminalWaitBlockedSignal } from './terminal-wait-detection' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' export function buildTerminalWaitText( lines: string[], @@ -64,7 +64,7 @@ function inspectTerminalWaitTail( // Why the index: proving a signal is ABSENT can't early-exit, so a full re-test of the // 2000-line tail ran per scan; the index tests only the lines each append produced. mayContainBlockedSignal: - tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + tailMayContainBlockedSignal(lines) || terminalWaitBlockedSentinelRe().test(partialLine) } } diff --git a/src/main/runtime/tui-idle-evidence-source.ts b/src/main/runtime/tui-idle-evidence-source.ts new file mode 100644 index 00000000000..3a1b5277999 --- /dev/null +++ b/src/main/runtime/tui-idle-evidence-source.ts @@ -0,0 +1,148 @@ +// Gathers each tui-idle evaluation's evidence from runtime state; tui-idle-evidence.ts ranks it. +import type { AgentStatus } from '../../shared/agent-detection' +import type { TuiAgent } from '../../shared/tui-agent' +import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { + detectTerminalWaitBlockedReason, + isKnownReadyPromptBody, + isQuietReadyScreenBody +} from './terminal-wait-detection' +import { readScreenInputVeto, type RuledScreen } from './screen-input-veto' +import { + evaluateAgentStateRules, + readsTrustedScreen, + type AgentStateVerdict +} from './agent-state-rules/agent-state-rules-engine' +import type { TuiIdleHookTurn } from './tui-idle-hook-lane' +import type { + FirstPartyAgentStatus, + TuiIdleEvaluationInput, + TuiIdleEvidenceRecord +} from './tui-idle-evidence' + +/** The runtime state every tui-idle site reads its evidence from. */ +export type TuiIdleEvidenceSource = { + quiescenceMs: number + getTabTitle(tabId: string): string | null + getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null + getPaneAgent(ptyId: string | null | undefined): TuiAgent | null + getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus + /** The hook server's fresh row for the pane's main agent; absent on a host with no store. */ + getHookTurn?(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null + readScreenLines(ptyId: string | null | undefined): readonly string[] | null + /** The painted grid on the PTY's own size, which only agents whose rules read the trusted + * screen, and screen vetoes, use. Absent, they have no trustworthy screen. */ + readRuledScreen?(ptyId: string | null | undefined): RuledScreen | null + /** When the PTY last observed a title of its own. Absent, no title has an age. */ + getTitleObservedAtEpochMs?(ptyId: string | null | undefined): number | null +} + +// Why per agent: every other agent keeps the live screen its rules were recorded against. +// Why read once: several lanes consult the rules, and one evaluation sees one screen. +function screenReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): () => readonly string[] | null { + let lines: readonly string[] | null | undefined + const read = readsTrustedScreen(agent) + ? () => source.readRuledScreen?.(ptyId)?.lines ?? null + : () => source.readScreenLines(ptyId) + return () => (lines === undefined ? (lines = read()) : lines) +} + +function readAgentRuleVerdict( + agent: TuiAgent | null, + record: TuiIdleEvidenceRecord, + readScreenLines: () => readonly string[] | null, + waitText: () => string +): AgentStateVerdict | null { + return evaluateAgentStateRules(agent, { + readScreenLines, + readText: () => waitText().toLowerCase(), + readTitleStatus: () => record.lastAgentStatus, + hasOutputClock: record.lastOutputAt !== null + }) +} + +function hookTurnReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): (() => TuiIdleHookTurn | null) | undefined { + return source.getHookTurn && agent && ptyId + ? () => source.getHookTurn?.(ptyId, agent) ?? null + : undefined +} + +function screenInputVetoReader( + source: TuiIdleEvidenceSource, + agent: TuiAgent | null, + ptyId: string | null | undefined +): () => boolean | null { + // Why memoized: the OMP lane and the veto over the verdict both ask within one evaluation. + let read = false + let veto: boolean | null = null + return () => { + if (!read) { + veto = readScreenInputVeto(agent, () => source.readRuledScreen?.(ptyId) ?? null) + read = true + } + return veto + } +} + +function lazyWaitText(readWaitText: () => string): () => string { + let waitText: string | null = null + return () => (waitText ??= readWaitText()) +} + +export function leafTuiIdleEvidence( + source: TuiIdleEvidenceSource, + leaf: RuntimeLeafRecord, + readWaitText: () => string +): TuiIdleEvaluationInput { + const waitText = lazyWaitText(readWaitText) + const agent = source.getPaneAgent(leaf.ptyId) + const readScreen = screenReader(source, agent, leaf.ptyId) + return { + record: leaf, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), + rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), + readPositiveBodyEvidence: () => + isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), + readScreenInputVeto: screenInputVetoReader(source, agent, leaf.ptyId), + titleObservedAtEpochMs: source.getTitleObservedAtEpochMs?.(leaf.ptyId) ?? null, + agent, + firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), + readHookTurn: hookTurnReader(source, agent, leaf.ptyId), + quiescenceMs: source.quiescenceMs + } +} + +export function ptyTuiIdleEvidence( + source: TuiIdleEvidenceSource, + pty: RuntimePtyWorktreeRecord, + readWaitText: () => string +): TuiIdleEvaluationInput { + const waitText = lazyWaitText(readWaitText) + const agent = source.getPaneAgent(pty.ptyId) + const readScreen = screenReader(source, agent, pty.ptyId) + return { + record: pty, + readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), + readPositiveBodyEvidence: () => + (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || + isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), + readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), + readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), + readScreenInputVeto: screenInputVetoReader(source, agent, pty.ptyId), + titleObservedAtEpochMs: pty.lastOscTitleEpochMs, + agent, + firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), + readHookTurn: hookTurnReader(source, agent, pty.ptyId), + quiescenceMs: source.quiescenceMs + } +} diff --git a/src/main/runtime/tui-idle-evidence.test.ts b/src/main/runtime/tui-idle-evidence.test.ts index 43c9789af12..c0016b38de7 100644 --- a/src/main/runtime/tui-idle-evidence.test.ts +++ b/src/main/runtime/tui-idle-evidence.test.ts @@ -36,6 +36,8 @@ function input(overrides: Partial = {}): TuiIdleEvaluati readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => true, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, agent: 'muse', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, @@ -275,6 +277,8 @@ describe('a DSH pane settles tui-idle on its own hook', () => { readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, readTailBlockedReason: () => null, agent: 'dsh' as const, firstPartyStatus: { state: 'done' as const, updatedAt: Date.now() }, @@ -306,3 +310,61 @@ describe('a DSH pane settles tui-idle on its own hook', () => { expect(ready({ agent: 'claude' })).toBe(false) }) }) + +describe('evaluateTuiIdle screen input veto', () => { + const omp = (overrides: Partial = {}) => + input({ agent: 'omp', readQuietReadyBodyEvidence: () => false, ...overrides }) + + it('refuses every ready lane while the screen vetoes input', () => { + const lanes: Partial[] = [ + { record: record({ lastOscTitle: 'π - repo', lastAgentStatus: 'idle' }) }, + { readPositiveBodyEvidence: () => true }, + { firstPartyStatus: { state: 'done', updatedAt: Date.now(), sessionBoundary: true } }, + { record: record({ lastOscTitle: 'OMP', lastAgentStatus: 'idle' }) } + ] + for (const lane of lanes) { + expect(isTuiIdleReadyVerdict(evaluateTuiIdle(omp(lane)))).toBe(true) + expect(evaluateTuiIdle(omp({ ...lane, readScreenInputVeto: () => true }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + } + }) + + it("accepts OMP's own idle title once it has stood on a screen read clear of the wizard", () => { + const idle = { + record: record({ + lastOscTitle: 'π > repo', + lastAgentStatus: 'idle', + lastOutputAt: Date.now() + }), + titleObservedAtEpochMs: Date.now() - QUIESCENCE_MS + } + // Output still flowing (OMP's bracketed-paste keepalive) does not hold the title back. + expect(evaluateTuiIdle(omp({ ...idle, readScreenInputVeto: () => false }))).toEqual({ + kind: 'ready-strong' + }) + for (const unproven of [ + { readScreenInputVeto: () => null }, + { readScreenInputVeto: () => true }, + { + titleObservedAtEpochMs: Date.now() - QUIESCENCE_MS + 1_000, + readScreenInputVeto: () => false + }, + { titleObservedAtEpochMs: null, readScreenInputVeto: () => false } + ]) { + expect(isTuiIdleReadyVerdict(evaluateTuiIdle(omp({ ...idle, ...unproven })))).toBe(false) + } + }) + + it('keeps an OMP pane waiting on the user pending, so the poll still reads its screen', () => { + expect( + evaluateTuiIdle( + omp({ + record: record({ lastOscTitle: 'OMP', lastAgentStatus: 'idle' }), + firstPartyStatus: { state: 'blocked', updatedAt: Date.now() } + }) + ) + ).toEqual({ kind: 'pending', quietForeground: 'closed' }) + }) +}) diff --git a/src/main/runtime/tui-idle-evidence.ts b/src/main/runtime/tui-idle-evidence.ts index ce51d3104c5..2c28f335232 100644 --- a/src/main/runtime/tui-idle-evidence.ts +++ b/src/main/runtime/tui-idle-evidence.ts @@ -9,18 +9,11 @@ import { getSyntheticAgentTerminalTitle } from '../../shared/synthetic-agent-tit import { resolveExplicitTerminalTitleAgentType } from '../../shared/terminal-title-agent-type' import type { TuiAgent } from '../../shared/tui-agent' import { getTuiAgentRestSignal } from '../../shared/tui-agent-rest-signal' -import type { RuntimeLeafRecord, RuntimePtyWorktreeRecord } from './runtime-terminal-state-records' +import { detectExplicitIdleStatusFromTitle } from './terminal-wait-detection' +import { isOmpIdleStateTitle } from './omp-terminal-readiness' import { - detectExplicitIdleStatusFromTitle, - detectTerminalWaitBlockedReason, - isKnownReadyPromptBody, - isQuietReadyScreenBody -} from './terminal-wait-detection' -import { - evaluateAgentStateRules, hasQuietReadyRules, idleTitleRequiresQuiet, - readsTrustedScreen, type AgentStateVerdict } from './agent-state-rules/agent-state-rules-engine' import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' @@ -42,7 +35,9 @@ import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' * title, or a known ready-prompt body. * 1b. QUIET READY SCREEN — Muse titles no rest signal, and agents whose rules * (agent-state-rules/) read a ready screen or text they also paint mid-turn (Codex's - * header and composer), so that body is believed only once quiet. + * header and composer), so that body is believed only once quiet. OMP's own idle title is + * believed once it has stood that long, because it paints that title before its setup + * wizard opens. * 2. WORKING — a fresh first-party agent status (OSC 9999) saying working/blocked/ * waiting, or a working title. The agent's own account of itself outranks anything * inferred. @@ -53,6 +48,8 @@ import { evaluateHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' * start-up dialog the line tail lost (Claude's workspace trust), so ONLY the poll may settle * on it, after the rendered screen shows no blocker. Synchronous sites settle on tiers 0-1b. * + * Over every ready tier: a screen veto (OMP's setup wizard) refuses input whichever lane settled. + * * Why derived here rather than stamped onto the record at write time: `syncWindowGraph` * rebuilds every leaf from an explicit field list, so a bespoke provenance field is * silently dropped on any renderer publish and the verdict silently flips. `lastOscTitle` @@ -76,16 +73,9 @@ export function hasExplicitIdleTitle( record: TuiIdleEvidenceRecord, rendererTitle?: string | null ): boolean { - // Why lastOscTitle too, not just the renderer's pane title: a daemon-hosted or - // background pane has no renderer publishing a title, so reading only the synced - // one dropped an explicit `Codex ready` to the tier-3 lane and delayed it by the - // whole quiescence window. - for (const title of [rendererTitle, record.lastOscTitle]) { - if (title && detectExplicitIdleStatusFromTitle(title) === 'idle') { - return true - } - } - return false + // Renderer titles may be display-only decay; native evidence takes precedence. + const title = record.lastOscTitle ?? rendererTitle + return Boolean(title && detectExplicitIdleStatusFromTitle(title) === 'idle') } /** @@ -211,6 +201,10 @@ export type TuiIdleEvaluationInput = { readQuietReadyBodyEvidence: () => boolean /** The agent's own rules' answer; any answer shuts the lanes that cannot see its screen. */ readAgentRuleVerdict: () => AgentStateVerdict | null + /** Whether an overlay on the agent's screen refuses input; null with no rule or no screen. */ + readScreenInputVeto: () => boolean | null + /** When the PTY's own current title was observed; null when it has none or no clock. */ + titleObservedAtEpochMs: number | null agent: TuiAgent | null | undefined firstPartyStatus: FirstPartyAgentStatus /** Tier 0: the hook server's fresh row for the pane, read only for an authoritative agent. */ @@ -262,6 +256,15 @@ export function hasQuietReadyScreen( /** The one place the tiers are combined; every settle site branches only on the verdict. */ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { + const verdict = rankTuiIdleEvidence(input) + // Why over the verdict rather than per lane: an overlay refuses input whichever lane would + // settle, and only a ready verdict pays for the screen read. + return isTuiIdleReadyVerdict(verdict) && input.readScreenInputVeto() === true + ? { kind: 'pending', quietForeground: 'closed' } + : verdict +} + +function rankTuiIdleEvidence(input: TuiIdleEvaluationInput): TuiIdleVerdict { const hookVerdict = input.readHookTurn ? evaluateHookTurn(input.agent, input.readHookTurn) : null if (hookVerdict) { return hookVerdict @@ -299,6 +302,17 @@ export function evaluateTuiIdle(input: TuiIdleEvaluationInput): TuiIdleVerdict { ? WORKING : { kind: 'pending', quietForeground: 'closed' } } + // OMP paints `π >` before the rest of its startup runs and its setup wizard opens, so the title + // counts only once it has stood a quiescence window on a screen read clear of the wizard. Why + // title age, not output quiet: OMP re-asserts bracketed paste every second once a terminal + // answers its probe. Unreadable, the screen cannot rule setup out, and no lane may settle. + if (input.agent === 'omp' && isOmpIdleStateTitle(input.record.lastOscTitle)) { + return input.titleObservedAtEpochMs !== null && + Date.now() - input.titleObservedAtEpochMs >= input.quiescenceMs && + input.readScreenInputVeto() === false + ? READY_STRONG + : { kind: 'pending', quietForeground: 'closed' } + } // Why after the veto: a first-party working account outranks inferred body evidence. // Why before the working title: Codex can leave a stale spinner title after a turn, and a // live spinner emits output every ~100 ms, so a spinning pane is never quiet here. @@ -348,107 +362,3 @@ function isSettledWeakIdle( export function isTuiIdleReadyVerdict(verdict: TuiIdleVerdict): boolean { return verdict.kind === 'ready-strong' || verdict.kind === 'ready-weak' } - -/** The runtime state every tui-idle site reads its evidence from. */ -export type TuiIdleEvidenceSource = { - quiescenceMs: number - getTabTitle(tabId: string): string | null - getAdoptedPtyIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null - getPaneAgent(ptyId: string | null | undefined): TuiAgent | null - getFirstPartyAgentStatus(ptyId: string | null | undefined): FirstPartyAgentStatus - /** The hook server's fresh row for the pane's main agent; absent on a host with no store. */ - getHookTurn?(ptyId: string, agent: TuiAgent): TuiIdleHookTurn | null - readScreenLines(ptyId: string | null | undefined): readonly string[] | null - /** The painted rows on the PTY's own grid, which only agents whose rules read the trusted - * screen use. Absent, they have no trustworthy screen. */ - readScreenRuledLines?(ptyId: string | null | undefined): readonly string[] | null -} - -// Why per agent: every other agent keeps the live screen its rules were recorded against. -// Why read once: several lanes consult the rules, and one evaluation sees one screen. -function screenReader( - source: TuiIdleEvidenceSource, - agent: TuiAgent | null, - ptyId: string | null | undefined -): () => readonly string[] | null { - let lines: readonly string[] | null | undefined - const read = readsTrustedScreen(agent) - ? () => source.readScreenRuledLines?.(ptyId) ?? null - : () => source.readScreenLines(ptyId) - return () => (lines === undefined ? (lines = read()) : lines) -} - -function readAgentRuleVerdict( - agent: TuiAgent | null, - record: TuiIdleEvidenceRecord, - readScreenLines: () => readonly string[] | null, - waitText: () => string -): AgentStateVerdict | null { - return evaluateAgentStateRules(agent, { - readScreenLines, - readText: () => waitText().toLowerCase(), - readTitleStatus: () => record.lastAgentStatus, - hasOutputClock: record.lastOutputAt !== null - }) -} - -function hookTurnReader( - source: TuiIdleEvidenceSource, - agent: TuiAgent | null, - ptyId: string | null | undefined -): (() => TuiIdleHookTurn | null) | undefined { - return source.getHookTurn && agent && ptyId - ? () => source.getHookTurn?.(ptyId, agent) ?? null - : undefined -} - -function lazyWaitText(readWaitText: () => string): () => string { - let waitText: string | null = null - return () => (waitText ??= readWaitText()) -} - -export function leafTuiIdleEvidence( - source: TuiIdleEvidenceSource, - leaf: RuntimeLeafRecord, - readWaitText: () => string -): TuiIdleEvaluationInput { - const waitText = lazyWaitText(readWaitText) - const agent = source.getPaneAgent(leaf.ptyId) - const readScreen = screenReader(source, agent, leaf.ptyId) - return { - record: leaf, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), - rendererTitle: leaf.paneTitle ?? source.getTabTitle(leaf.tabId), - readPositiveBodyEvidence: () => - isKnownReadyPromptBody(waitText(), agent, readScreen, leaf.lastOutputAt !== null), - readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, leaf, readScreen, waitText), - agent, - firstPartyStatus: source.getFirstPartyAgentStatus(leaf.ptyId), - readHookTurn: hookTurnReader(source, agent, leaf.ptyId), - quiescenceMs: source.quiescenceMs - } -} - -export function ptyTuiIdleEvidence( - source: TuiIdleEvidenceSource, - pty: RuntimePtyWorktreeRecord, - readWaitText: () => string -): TuiIdleEvaluationInput { - const waitText = lazyWaitText(readWaitText) - const agent = source.getPaneAgent(pty.ptyId) - const readScreen = screenReader(source, agent, pty.ptyId) - return { - record: pty, - readTailBlockedReason: () => detectTerminalWaitBlockedReason(waitText()), - readPositiveBodyEvidence: () => - (agent !== 'qoder' && source.getAdoptedPtyIdleStatus(pty) === 'idle') || - isKnownReadyPromptBody(waitText(), agent, readScreen, pty.lastOutputAt !== null), - readQuietReadyBodyEvidence: () => isQuietReadyScreenBody(waitText(), agent, readScreen), - readAgentRuleVerdict: () => readAgentRuleVerdict(agent, pty, readScreen, waitText), - agent, - firstPartyStatus: source.getFirstPartyAgentStatus(pty.ptyId), - readHookTurn: hookTurnReader(source, agent, pty.ptyId), - quiescenceMs: source.quiescenceMs - } -} diff --git a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts index 312bd3b8e67..fd9f35e8065 100644 --- a/src/main/runtime/tui-idle-hook-lane-runtime.test.ts +++ b/src/main/runtime/tui-idle-hook-lane-runtime.test.ts @@ -27,6 +27,15 @@ const OPENCODE_PERMISSION_DIALOG = [ ' Allow once Allow always Reject ctrl+f fullscreen ⇆ select enter confirm', '' ].join('\r\n') +// OpenCode 1.18's question tool, which paints no dialog wording the blocked layer knows. +const OPENCODE_QUESTION = [ + ' ┃ Which color do you prefer?', + ' ┃ 1. Red', + ' ┃ 2. Blue', + ' ┃ 3. Type your own answer', + ' ┃ ↑↓ select enter submit esc dismiss', + '' +].join('\r\n') // Shorter than the 2 s poll, so only the synchronous verdict can settle a wait. const WAIT_MS = 150 @@ -167,13 +176,14 @@ describe('tui-idle hook lane through the runtime', () => { ).toBe('ready') }) - // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so its row can stay - // working; its title and screen rules decide instead. - it('leaves Codex to its screen rules, whatever its hook row says', async () => { + // Why: Codex before its Interrupt hook sends nothing for an Esc mid-turn, so only its done + // decides; a working row leaves its title and screen rules to decide. + it('settles Codex on its done, and leaves a working row to its screen rules', async () => { const codex = { launchAgent: 'codex' as const, data: CODEX_BUSY_SCREEN } - expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe( - 'timeout' - ) + expect(await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex' })] })).toBe('ready') + expect( + await waitOutcome({ ...codex, rows: () => [row({ agentType: 'codex', state: 'working' })] }) + ).toBe('timeout') }) it("settles past a denied prompt's dialog text once the hook says the turn ended", async () => { @@ -194,4 +204,21 @@ describe('tui-idle hook lane through the runtime', () => { }) ).toBe('ready') }) + + it('blocks on an open question the hook reports, and settles once it is answered', async () => { + const options = { launchAgent: 'opencode' as const, data: OPENCODE_QUESTION } + expect(await waitOutcome({ ...options, rows: () => [] })).toBe('timeout') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', state: 'waiting', receivedAt: Date.now() + 1 })] + }) + ).toBe('blocked:agent-interactive-prompt') + expect( + await waitOutcome({ + ...options, + rows: () => [row({ agentType: 'opencode', receivedAt: Date.now() + 1 })] + }) + ).toBe('ready') + }) }) diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index 87683f266cf..c282fd1fb85 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -3,7 +3,7 @@ import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusIpcPayload } from '../../shared/agent-status-types' -import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { hookAuthority } from './agent-state-rules/agent-state-rules-engine' import { parseAgentStateRuleFiles } from './agent-state-rules/agent-state-rules-catalog' import { hookLeadTurnState, readTuiIdleHookTurn, type TuiIdleHookTurn } from './tui-idle-hook-lane' import { @@ -160,6 +160,24 @@ describe('readTuiIdleHookTurn', () => { ).toEqual(WORKING) }) + it('blocks on a wait the hook reports with no dialog text the arbiter knows (a question)', () => { + const receivedAt = Date.now() - 1000 + for (const state of ['waiting', 'blocked'] as const) { + expect(readTuiIdleHookTurn({ ...base, hookRows: [row({ receivedAt, state })] })).toEqual({ + state: 'permission', + blockedReason: 'agent-interactive-prompt' + }) + } + // Input since the wait opened may have answered it before the next hook arrived. + expect( + readTuiIdleHookTurn({ + ...base, + hookRows: [row({ receivedAt, state: 'waiting' })], + lastInputAt: receivedAt + 1 + }) + ).toEqual({ state: 'permission', blockedReason: null }) + }) + it('takes the newest joined row', () => { const now = Date.now() expect( @@ -190,10 +208,12 @@ describe('readTuiIdleHookTurn', () => { describe('profile.hooks', () => { it('marks the agents whose hooks report every turn end as authoritative', () => { for (const agent of ['opencode', 'opencode2', 'pi', 'omp'] as const) { - expect(hooksAreAuthoritative(agent)).toBe(true) + expect(hookAuthority(agent)).toBe('authoritative') } - for (const agent of ['codex', 'claude', 'cursor', 'gemini', 'grok', null] as const) { - expect(hooksAreAuthoritative(agent)).toBe(false) + // Codex before its Interrupt hook posts nothing for an Esc, so only its done is trusted. + expect(hookAuthority('codex')).toBe('turn-end') + for (const agent of ['claude', 'cursor', 'gemini', 'grok', null] as const) { + expect(hookAuthority(agent)).toBe('identity-only') } }) @@ -217,6 +237,8 @@ function input(overrides: Partial = {}): TuiIdleEvaluati readPositiveBodyEvidence: () => false, readQuietReadyBodyEvidence: () => false, readAgentRuleVerdict: () => null, + readScreenInputVeto: () => null, + titleObservedAtEpochMs: null, agent: 'pi', firstPartyStatus: null, quiescenceMs: QUIESCENCE_MS, @@ -293,12 +315,65 @@ describe('evaluateTuiIdle hook lane', () => { ).toEqual({ kind: 'pending', quietForeground: 'closed' }) }) - it.each(['claude', 'codex'] as const)('never reads hooks for identity-only %s', (agent) => { - const readHookTurn = vi.fn(() => ({ state: 'done' as const, blockedReason: null })) - expect(evaluateTuiIdle(input({ agent, readHookTurn }))).toEqual({ + it('never reads hooks for identity-only claude', () => { + const readHookTurn = vi.fn(() => DONE) + expect(evaluateTuiIdle(input({ agent: 'claude', readHookTurn }))).toEqual({ kind: 'pending', quietForeground: 'closed' }) expect(readHookTurn).not.toHaveBeenCalled() }) }) + +describe('evaluateTuiIdle turn-end hook lane (codex)', () => { + it('settles at once on a done turn, with no title and a streaming pane (headless)', () => { + expect(evaluateTuiIdle(input({ agent: 'codex', readHookTurn: () => DONE }))).toEqual({ + kind: 'ready-strong' + }) + }) + + it('still blocks on a done turn whose arbiter confirms the blocked text', () => { + expect( + evaluateTuiIdle( + input({ + agent: 'codex', + readHookTurn: () => ({ state: 'done', blockedReason: 'agent-approval-prompt' }) + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-approval-prompt' }) + }) + + it('lets the rules settle over a working row an Esc left behind (Codex before Interrupt)', () => { + expect( + evaluateTuiIdle( + input({ + agent: 'codex', + readQuietReadyBodyEvidence: () => true, + record: record({ lastOutputAt: Date.now() - QUIESCENCE_MS }), + readHookTurn: () => WORKING + }) + ) + ).toEqual({ kind: 'ready-strong' }) + }) + + it('leaves a working or permission row to the rules, which hold a busy pane', () => { + for (const turn of [ + WORKING, + { state: 'permission', blockedReason: 'agent-approval-prompt' } as const + ]) { + expect(evaluateTuiIdle(input({ agent: 'codex', readHookTurn: () => turn }))).toEqual({ + kind: 'pending', + quietForeground: 'closed' + }) + expect( + evaluateTuiIdle( + input({ + agent: 'codex', + readTailBlockedReason: () => 'agent-trust-workspace', + readHookTurn: () => turn + }) + ) + ).toEqual({ kind: 'blocked', reason: 'agent-trust-workspace' }) + } + }) +}) diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts index 04bfaa1bbdd..e8ef4c1e724 100644 --- a/src/main/runtime/tui-idle-hook-lane.ts +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -1,14 +1,15 @@ import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-types' import type { TuiAgent } from '../../shared/tui-agent' -import { hooksAreAuthoritative } from './agent-state-rules/agent-state-rules-engine' +import { hookAuthority } from './agent-state-rules/agent-state-rules-engine' import { selectFreshExplicitAgentStatusRow } from './runtime-hook-agent-row-selection' type HookTurnState = 'done' | 'working' | 'permission' /** * The main agent's turn as the hook server's store last saw it for one pane, and the permission - * arbiter's verdict on the pane's blocked text with that turn as its explicit status. + * arbiter's verdict on the pane's blocked text with that turn as its explicit status (or, for a + * wait the hook reports with no text the arbiter knows, a generic interactive prompt). */ export type TuiIdleHookTurn = { state: HookTurnState @@ -65,10 +66,19 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn return null } const state = hookLeadTurnState(row) - if (state === null || (state === 'done' && row.receivedAt < (read.lastInputAt ?? -1))) { + const predatesInput = row.receivedAt < (read.lastInputAt ?? -1) + if (state === null || (state === 'done' && predatesInput)) { return null } - return { state, blockedReason: read.resolveBlockedText(state, row) } + const blockedReason = read.resolveBlockedText(state, row) + // Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows, + // and these hooks report its answer. Input since may have answered it before the hook arrived. + return { + state, + blockedReason: + blockedReason ?? + (state === 'permission' && !predatesInput ? 'agent-interactive-prompt' : null) + } } /** The tui-idle verdicts the hook lane can reach (a subset of `TuiIdleVerdict`). */ @@ -79,20 +89,25 @@ export type TuiIdleHookVerdict = | { kind: 'pending'; quietForeground: 'closed' } /** - * Tier 0 of tui-idle-evidence.ts for an agent whose hooks are authoritative. Why ahead of every - * rule: its hooks report each way a turn ends, and they reach a headless host, where the - * ` ready` titles the window writes never appear (#16095). Why the arbiter judges the blocked - * text: a denied prompt's dialog lingers in the line tail after the hook says the turn moved on. - * No fresh row (startup, before the first prompt, or an unjoinable pane) leaves the other tiers. + * Tier 0 of tui-idle-evidence.ts for an agent whose hooks are trusted (`profile.hooks`). Why ahead + * of every rule: they reach a headless host, where the ` ready` titles the window writes + * never appear (#16095). Why the arbiter judges the blocked text: a denied prompt's dialog lingers + * in the line tail after the hook says the turn moved on. No fresh row (startup, before the first + * prompt, or an unjoinable pane) leaves the other tiers. */ export function evaluateHookTurn( agent: TuiAgent | null | undefined, readHookTurn: () => TuiIdleHookTurn | null ): TuiIdleHookVerdict | null { - if (!hooksAreAuthoritative(agent)) { + const authority = hookAuthority(agent) + if (authority === 'identity-only') { return null } const turn = readHookTurn() + // Why only a done for `turn-end`: an end that posts nothing leaves the row working forever. + if (authority === 'turn-end' && turn?.state !== 'done') { + return null + } if (turn?.blockedReason) { return { kind: 'blocked', reason: turn.blockedReason } } @@ -104,7 +119,7 @@ export function evaluateHookTurn( case 'working': return { kind: 'working' } case 'permission': - // Why pending: the arbiter saw no prompt in the tail, so the poll's screen read decides. + // Why pending: input after the wait opened may have answered it; the next hook decides. return { kind: 'pending', quietForeground: 'closed' } } } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cfd0fa29402..9da3d7212ef 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -32,6 +32,8 @@ import { AgentStatusObservedPaneIdentities, recordObservedAgentStatusPaneIdentity } from '../runtime/agent-status-observed-pane-identity' +import { startAgentStateRulesLiveUpdates } from '../runtime/agent-state-rules/agent-state-rules-live-update' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { const activation = state.desktopActivationGate @@ -162,6 +164,10 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { getScopeCatalog: () => sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID) }) app.once('will-quit', () => sessionSearch?.dispose()) + // Why here: this runs for the desktop and headless `orca serve`, and each evaluates its own panes. + startAgentStateRulesLiveUpdates(store, (rules) => + recordDurableCrashBreadcrumb('agent_state_rules_active', rules) + ) state.runtime = runtime agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) diff --git a/src/main/updater-agent-state-rules-release.test.ts b/src/main/updater-agent-state-rules-release.test.ts new file mode 100644 index 00000000000..880f46c1087 --- /dev/null +++ b/src/main/updater-agent-state-rules-release.test.ts @@ -0,0 +1,119 @@ +// Regression: agent state rules releases live in the app's own release feed, and must never change +// what the updater resolves on any path: the stable Latest feed, the RC prerelease feed, or the +// build picker. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getVersionChannel } from '../shared/release-channel' +import { installNetRequestFetchAdapter } from './updater-net-request.fixture' + +const { netFetchMock, netRequestMock } = vi.hoisted(() => ({ + netFetchMock: vi.fn(), + netRequestMock: vi.fn() +})) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock, request: netRequestMock } })) +vi.mock('./updater-release-api-token', () => ({ + resolveReleaseApiToken: async () => null, + rejectReleaseApiToken: () => {} +})) +vi.mock('./git/gh-rate-limit-breaker', () => ({ + getGhRateLimitBlockedUntilMs: () => null, + recordGhPrimaryRateLimit: () => {} +})) + +const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') +const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds') + +const RULES_TAGS = ['agent-state-rules-engine-1-next', 'agent-state-rules-engine-1-stable'] +const APP_TAGS = ['v1.4.3-rc.1', 'v1.4.2', 'v1.4.2-rc.4', 'v1.4.1'] + +function atomFeed(tags: readonly string[]): string { + const entries = tags + .map( + (tag) => + `${tag}` + ) + .join('') + return `${entries}` +} + +function serveFeed(tags: readonly string[]): void { + netFetchMock.mockImplementation((url: string) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ ok: true, text: () => Promise.resolve(atomFeed(tags)) }) + } + const manifest = url.match(/\/releases\/download\/v([^/]+)\/latest(?:-[a-z]+)?\.yml$/) + if (manifest) { + const version = manifest[1] + return Promise.resolve({ + ok: true, + status: 200, + text: () => + Promise.resolve( + `version: ${version}\npath: Orca-${version}.zip\nfiles:\n - url: Orca-${version}.zip\n` + ) + }) + } + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }) + }) +} + +function apiRelease(tag: string, assets: readonly string[]) { + return { + tag_name: tag, + draft: false, + published_at: '2026-10-01T00:00:00Z', + html_url: `https://github.com/stablyai/orca/releases/tag/${tag}`, + assets: assets.map((name) => ({ name })) + } +} + +const APP_ASSETS = [ + 'latest-mac.yml', + 'orca-macos-arm64.dmg', + 'latest.yml', + 'orca-windows-setup.exe' +] + +beforeEach(() => { + netFetchMock.mockReset() + netRequestMock.mockReset() + installNetRequestFetchAdapter(netRequestMock, netFetchMock) +}) + +describe('agent state rules releases and the app updater', () => { + it('leave the RC prerelease feed resolving to the same app tag', async () => { + serveFeed(APP_TAGS) + const withoutRules = await fetchNewerReleaseTag('1.4.2-rc.4') + serveFeed([...RULES_TAGS, ...APP_TAGS]) + expect(await fetchNewerReleaseTag('1.4.2-rc.4')).toBe(withoutRules) + expect(withoutRules).toBe('v1.4.3-rc.1') + }) + + it('leave the stable check resolving to the same app tag', async () => { + serveFeed([...RULES_TAGS, ...APP_TAGS]) + expect(await fetchNewerReleaseTag('1.4.1', { includePrerelease: false })).toBe('v1.4.2') + }) + + it.each(['stable', 'rc'] as const)('never appear in the %s build picker', async (channel) => { + netFetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: () => + Promise.resolve([ + ...RULES_TAGS.map((tag) => apiRelease(tag, ['agent-state-rules.json'])), + ...APP_TAGS.map((tag) => apiRelease(tag, APP_ASSETS)) + ]) + }) + const builds = await listReleaseBuilds(channel, 'darwin') + expect(builds.map((build) => build.tag).filter((tag) => RULES_TAGS.includes(tag))).toEqual([]) + expect(builds.length).toBeGreaterThan(0) + }) + + it('cannot be pinned as a build or read as any app channel', () => { + for (const tag of RULES_TAGS) { + expect(getVersionChannel(tag)).toBeNull() + expect(() => resolveTargetBuild('stable', tag)).toThrow('not a valid release tag') + } + }) +}) diff --git a/src/main/workspace-create-event-fields.test.ts b/src/main/workspace-create-event-fields.test.ts new file mode 100644 index 00000000000..eab8bb78a37 --- /dev/null +++ b/src/main/workspace-create-event-fields.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from '../shared/telemetry-event-registry' +import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' +import { + bucketRepoFileCount, + bucketWorktreeCount, + workspaceCreateFailureFields, + workspaceCreateTimingFields +} from './workspace-create-event-fields' + +const quiet = { otherCreates: 0, preparations: 0 } + +describe('workspaceCreateTimingFields', () => { + it('maps a local create with a prepared-checkout hit', () => { + const fields = workspaceCreateTimingFields( + { + totalDurationMs: 1_000.4, + phases: [ + { phase: 'refresh_base_ref', startedAtMs: 0, durationMs: 200.6 }, + { phase: 'git_worktree_add', startedAtMs: 200, durationMs: 500 }, + { phase: 'prepared_checkout_claim', startedAtMs: 200, durationMs: 4 }, + { phase: 'prepared_checkout_wait', startedAtMs: 204, durationMs: 300 }, + { phase: 'list_created_worktree', startedAtMs: 700, durationMs: 100 } + ], + preparedCheckout: { + status: 'hit', + reset: 'base_moved', + origin: 'rearm', + buildMs: 30_000.4, + idleMs: 2_500 + }, + executionHost: 'local', + worktreeCount: 812 + }, + { + entryPoint: 'runtime', + concurrency: { otherCreates: 1, preparations: 2 }, + repoFacts: { postCheckoutHook: 'absent', indexEntryCount: 42_000 } + } + ) + + expect(fields).toEqual({ + total_ms: 1_000, + unattributed_ms: 200, + refresh_base_ref_ms: 201, + git_worktree_add_ms: 500, + prepared_checkout_claim_ms: 4, + prepared_checkout_wait_ms: 300, + list_created_worktree_ms: 100, + prepared_checkout: 'hit', + prepared_checkout_reset: 'base_moved', + prepared_checkout_origin: 'rearm', + prepared_checkout_build_ms: 30_000, + prepared_checkout_idle_ms: 2_500, + create_entry_point: 'runtime', + execution_host: 'local', + worktree_count_bucket: '301-1000', + concurrent_creates: 1, + concurrent_preparations: 2, + repo_file_count_bucket: '10k-100k', + post_checkout_hook: 'absent' + }) + expect( + eventSchemas.workspace_created.safeParse({ + source: 'sidebar', + from_existing_branch: false, + ...fields + }).success + ).toBe(true) + }) + + it('maps a miss with its reason and omits what the create did not learn', () => { + const fields = workspaceCreateTimingFields( + { + totalDurationMs: 50, + phases: [], + preparedCheckout: { status: 'miss', reason: 'none_armed' } + }, + { entryPoint: 'app', concurrency: quiet } + ) + + expect(fields).toEqual({ + total_ms: 50, + unattributed_ms: 50, + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'none_armed', + create_entry_point: 'app', + concurrent_creates: 0, + concurrent_preparations: 0 + }) + }) + + it('sends the hook but no file count when the repo has no usable index', () => { + const fields = workspaceCreateTimingFields( + { totalDurationMs: 1, phases: [] }, + { entryPoint: 'app', concurrency: quiet, repoFacts: { postCheckoutHook: 'unknown' } } + ) + expect(fields.post_checkout_hook).toBe('unknown') + expect(fields).not.toHaveProperty('repo_file_count_bucket') + }) + + it('takes the worktree count from the repo read when the create did not list worktrees', () => { + const fields = workspaceCreateTimingFields( + { totalDurationMs: 1, phases: [] }, + { + entryPoint: 'app', + concurrency: quiet, + repoFacts: { postCheckoutHook: 'absent', worktreeCount: 7 } + } + ) + expect(fields.worktree_count_bucket).toBe('6-20') + }) + + it('sums a repeated phase and drops names outside the closed vocabulary', () => { + const fields = workspaceCreateTimingFields( + { + totalDurationMs: 100, + phases: [ + { phase: 'refresh_base_ref', startedAtMs: 0, durationMs: 10 }, + { phase: 'refresh_base_ref', startedAtMs: 10, durationMs: 15 }, + { phase: '/Users/alice/repo', startedAtMs: 25, durationMs: 5 } + ] + }, + { entryPoint: 'app', concurrency: quiet } + ) + + expect(fields.refresh_base_ref_ms).toBe(25) + expect(Object.keys(fields).some((key) => key.includes('alice'))).toBe(false) + }) +}) + +describe('workspaceCreateFailureFields', () => { + it('names the failing phase and the elapsed time', async () => { + let now = 0 + const recorder = createWorktreeCreateTimingRecorder(() => now) + recorder.recordExecutionHost('ssh') + const error = await recorder + .time('git_worktree_add', async () => { + now = 4_200 + throw new Error('fatal: could not create work tree dir /Users/alice/x') + }) + .catch((caught: unknown) => caught) + + const fields = workspaceCreateFailureFields(recorder, { + entryPoint: 'app', + concurrency: { otherCreates: 3, preparations: 1 }, + error + }) + + expect(fields).toEqual({ + failed_phase: 'git_worktree_add', + total_ms: 4_200, + create_entry_point: 'app', + execution_host: 'ssh', + concurrent_creates: 3, + concurrent_preparations: 1 + }) + expect( + eventSchemas.workspace_create_failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + ...fields + }).success + ).toBe(true) + }) + + it('carries the prepared-checkout outcome and wait of a create that failed after it', async () => { + let now = 0 + const recorder = createWorktreeCreateTimingRecorder(() => now) + recorder.recordExecutionHost('local') + await recorder.time('prepared_checkout_wait', async () => { + now = 200_400 + }) + recorder.recordPreparedCheckout({ status: 'miss', reason: 'finalize_failed' }) + const error = await recorder + .time('git_worktree_add', async () => { + throw new Error('fatal: a branch named feature already exists') + }) + .catch((caught: unknown) => caught) + + const fields = workspaceCreateFailureFields(recorder, { + entryPoint: 'runtime', + concurrency: quiet, + error + }) + + expect(fields).toMatchObject({ + failed_phase: 'git_worktree_add', + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'finalize_failed', + prepared_checkout_wait_ms: 200_400, + create_entry_point: 'runtime' + }) + expect( + eventSchemas.workspace_create_failed.safeParse({ + source: 'unknown', + error_class: 'git_failed', + ...fields + }).success + ).toBe(true) + }) + + it('reports untimed when the create failed outside every phase', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + expect( + workspaceCreateFailureFields(recorder, { + entryPoint: 'app', + concurrency: quiet, + error: new Error('x') + }).failed_phase + ).toBe('untimed') + }) +}) + +describe('bucketWorktreeCount', () => { + it.each([ + [0, '1'], + [1, '1'], + [2, '2-5'], + [5, '2-5'], + [6, '6-20'], + [21, '21-100'], + [101, '101-300'], + [301, '301-1000'], + [1000, '301-1000'], + [1001, '1001+'] + ] as const)('%i worktrees -> %s', (count, bucket) => { + expect(bucketWorktreeCount(count)).toBe(bucket) + }) +}) + +describe('bucketRepoFileCount', () => { + it.each([ + [0, '<1k'], + [999, '<1k'], + [1_000, '1k-10k'], + [10_000, '10k-100k'], + [100_000, '100k-500k'], + [500_000, '500k+'] + ] as const)('%i files -> %s', (count, bucket) => { + expect(bucketRepoFileCount(count)).toBe(bucket) + }) +}) diff --git a/src/main/workspace-create-event-fields.ts b/src/main/workspace-create-event-fields.ts new file mode 100644 index 00000000000..921ff9e2c77 --- /dev/null +++ b/src/main/workspace-create-event-fields.ts @@ -0,0 +1,160 @@ +import type { z } from 'zod' +import type { WorktreeCreateTiming } from '../shared/worktree/create-types' +import { + WORKTREE_CREATE_PHASES, + type WorkspaceCreateEntryPoint, + type WorktreeCreatePhase +} from '../shared/worktree/create-timing-vocabulary' +import type { + REPO_FILE_COUNT_BUCKETS, + WORKTREE_COUNT_BUCKETS, + workspaceCreateFailedProperties, + workspaceCreatedTimingProperties +} from '../shared/telemetry-workspace-create-schemas' +import type { WorktreeCreateTimingRecorder } from './worktree-create-timing' +import type { WorktreeCreateConcurrency } from './worktree-create-concurrency' +import type { CreateEventRepoFacts } from './git/create-event-repo-probe' +import { worktreeCreateUnattributedMs } from './observability/instrumentation' + +type OptionalEventFields

> = { [K in keyof P]?: z.infer } +export type WorkspaceCreateTimingFields = OptionalEventFields< + typeof workspaceCreatedTimingProperties +> +export type WorkspaceCreateFailureFields = OptionalEventFields< + typeof workspaceCreateFailedProperties +> +type WorktreeCountBucket = (typeof WORKTREE_COUNT_BUCKETS)[number] +type RepoFileCountBucket = (typeof REPO_FILE_COUNT_BUCKETS)[number] + +/** What the create's owner knows beyond the timing the create recorded. */ +export type WorkspaceCreateEventContext = { + entryPoint: WorkspaceCreateEntryPoint + concurrency: WorktreeCreateConcurrency +} + +const KNOWN_PHASES: ReadonlySet = new Set(WORKTREE_CREATE_PHASES) + +function isKnownPhase(phase: string): phase is WorktreeCreatePhase { + return KNOWN_PHASES.has(phase) +} + +export function bucketWorktreeCount(count: number): WorktreeCountBucket { + if (count <= 1) { + return '1' + } + if (count <= 5) { + return '2-5' + } + if (count <= 20) { + return '6-20' + } + if (count <= 100) { + return '21-100' + } + if (count <= 300) { + return '101-300' + } + return count <= 1000 ? '301-1000' : '1001+' +} + +export function bucketRepoFileCount(count: number): RepoFileCountBucket { + if (count < 1_000) { + return '<1k' + } + if (count < 10_000) { + return '1k-10k' + } + if (count < 100_000) { + return '10k-100k' + } + return count < 500_000 ? '100k-500k' : '500k+' +} + +function phaseTotals(phases: WorktreeCreateTiming['phases']): Map { + const totals = new Map() + for (const phase of phases) { + // Unknown names are dropped rather than forwarded: only the closed vocabulary may leave. + if (isKnownPhase(phase.phase)) { + totals.set(phase.phase, (totals.get(phase.phase) ?? 0) + phase.durationMs) + } + } + return totals +} + +function phaseDurationFields(phases: WorktreeCreateTiming['phases']): WorkspaceCreateTimingFields { + const fields: WorkspaceCreateTimingFields = {} + for (const [phase, durationMs] of phaseTotals(phases)) { + fields[`${phase}_ms`] = Math.round(durationMs) + } + return fields +} + +function preparedCheckoutFields( + prepared: WorktreeCreateTiming['preparedCheckout'] +): WorkspaceCreateFailureFields { + if (prepared?.status === 'hit') { + return { + prepared_checkout: 'hit', + prepared_checkout_reset: prepared.reset, + prepared_checkout_origin: prepared.origin, + prepared_checkout_build_ms: Math.round(prepared.buildMs), + prepared_checkout_idle_ms: Math.round(prepared.idleMs) + } + } + return prepared + ? { prepared_checkout: 'miss', prepared_checkout_miss_reason: prepared.reason } + : {} +} + +function contextFields( + timing: WorktreeCreateTiming, + context: WorkspaceCreateEventContext +): WorkspaceCreateFailureFields { + return { + create_entry_point: context.entryPoint, + ...(timing.executionHost ? { execution_host: timing.executionHost } : {}), + concurrent_creates: context.concurrency.otherCreates, + concurrent_preparations: context.concurrency.preparations + } +} + +function worktreeCountFields(count: number | undefined): WorkspaceCreateTimingFields { + return count === undefined ? {} : { worktree_count_bucket: bucketWorktreeCount(count) } +} + +/** Event fields for a finished create, built only from what the create already measured. */ +export function workspaceCreateTimingFields( + timing: WorktreeCreateTiming, + context: WorkspaceCreateEventContext & { repoFacts?: CreateEventRepoFacts } +): WorkspaceCreateTimingFields { + const { repoFacts } = context + return { + total_ms: Math.round(timing.totalDurationMs), + unattributed_ms: worktreeCreateUnattributedMs(timing), + ...phaseDurationFields(timing.phases), + ...preparedCheckoutFields(timing.preparedCheckout), + ...contextFields(timing, context), + ...worktreeCountFields(repoFacts?.worktreeCount ?? timing.worktreeCount), + ...(repoFacts?.indexEntryCount !== undefined + ? { repo_file_count_bucket: bucketRepoFileCount(repoFacts.indexEntryCount) } + : {}), + ...(repoFacts ? { post_checkout_hook: repoFacts.postCheckoutHook } : {}) + } +} + +/** Event fields for a failed create: where it died, how long it had run, and what the prepared + * checkout did before it. */ +export function workspaceCreateFailureFields( + recorder: Pick, + context: WorkspaceCreateEventContext & { error: unknown } +): WorkspaceCreateFailureFields { + const timing = recorder.finish() + const waitMs = phaseTotals(timing.phases).get('prepared_checkout_wait') + return { + failed_phase: recorder.failedPhase(context.error) ?? 'untimed', + total_ms: Math.round(timing.totalDurationMs), + ...preparedCheckoutFields(timing.preparedCheckout), + ...(waitMs !== undefined ? { prepared_checkout_wait_ms: Math.round(waitMs) } : {}), + ...contextFields(timing, context) + } +} diff --git a/src/main/workspace-create-telemetry.ts b/src/main/workspace-create-telemetry.ts new file mode 100644 index 00000000000..a1dcc4d9f7d --- /dev/null +++ b/src/main/workspace-create-telemetry.ts @@ -0,0 +1,160 @@ +import type { WorktreeCreateTiming } from '../shared/worktree/create-types' +import type { WorkspaceCreateEntryPoint } from '../shared/worktree/create-timing-vocabulary' +import { workspaceSourceSchema } from '../shared/telemetry-events' +import type { EventProps, WorkspaceSource } from '../shared/telemetry-events' +import { isTelemetryEnabled, track } from './telemetry/client' +import { getCohortAtEmit } from './telemetry/cohort-classifier' +import { classifyWorkspaceCreateError } from './ipc/workspace-create-error-classifier' +import { probeCreateEventRepoFacts } from './git/create-event-repo-probe' +import { + createWorktreeCreateTimingRecorder, + type WorktreeCreateTimingRecorder +} from './worktree-create-timing' +import { beginWorktreeCreate } from './worktree-create-concurrency' +import { + workspaceCreateFailureFields, + workspaceCreateTimingFields, + type WorkspaceCreateEventContext +} from './workspace-create-event-fields' + +/** One create's `workspace_created` / `workspace_create_failed` event: exactly one is sent. */ +export type WorkspaceCreateTelemetry = { + /** The recorder the create times its phases into. */ + readonly timing: WorktreeCreateTimingRecorder + /** `timing` is the create's own finished timing when it returned one. */ + succeeded(timing?: WorktreeCreateTiming): void + failed(error: unknown): void +} + +export type WorkspaceCreateTelemetryArgs = { + /** The caller-supplied value, validated here; anything else is sent as `unknown`. */ + source: unknown + entryPoint: WorkspaceCreateEntryPoint + repoPath: string + fromExistingBranch: boolean + /** Folder workspaces register a directory: no timing, no disk competition, event unchanged. */ + isFolder: boolean +} + +/** + * Starts the events for one create. Both create entry points (the app's IPC handler and the + * runtime API) begin here, so a create is counted once and both send the same fields. + */ +export function beginWorkspaceCreateTelemetry( + args: WorkspaceCreateTelemetryArgs +): WorkspaceCreateTelemetry { + const sourceParse = workspaceSourceSchema.safeParse(args.source) + const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown' + const inFlight = args.isFolder ? null : beginWorktreeCreate() + const timing = createWorktreeCreateTimingRecorder(undefined, inFlight ?? undefined) + let settled = false + const settleOnce = (send: () => void): void => { + if (settled) { + return + } + settled = true + try { + send() + } catch (error) { + // Bookkeeping must never change the outcome of the create it describes. + console.warn('[worktree-create] create event could not be sent', error) + } finally { + inFlight?.end() + } + } + + return { + timing, + succeeded(createTiming) { + settleOnce(() => { + const props: EventProps<'workspace_created'> = { + source, + from_existing_branch: !args.isFolder && args.fromExistingBranch, + ...getCohortAtEmit() + } + if (!inFlight) { + track('workspace_created', props) + return + } + void trackWorkspaceCreated(props, args.repoPath, createTiming ?? timing.finish(), { + entryPoint: args.entryPoint, + concurrency: inFlight.end() + }).catch((error: unknown) => { + console.warn('[worktree-create] create event could not be sent', error) + }) + }) + }, + failed(error) { + settleOnce(() => { + track('workspace_create_failed', { + source, + error_class: classifyWorkspaceCreateError(error), + ...getCohortAtEmit(), + ...(inFlight + ? workspaceCreateFailureFields(timing, { + entryPoint: args.entryPoint, + concurrency: inFlight.end(), + error + }) + : {}) + }) + }) + } + } +} + +/** Lets a runtime create start its events once it is known to be a Git create. */ +export type RuntimeWorkspaceCreateEvents = { + begin(repoPath: string): WorktreeCreateTimingRecorder +} + +/** + * Runs a runtime create and settles its events before returning, so a re-arm the caller fires + * afterwards is not counted against it. Folder workspaces and requests rejected before `begin` + * send nothing, as before. + */ +export async function trackRuntimeWorkspaceCreate( + request: { telemetrySource?: unknown; baseBranch?: string }, + perform: (events: RuntimeWorkspaceCreateEvents) => Promise +): Promise { + const session: { current?: WorkspaceCreateTelemetry } = {} + try { + const result = await perform({ + begin(repoPath) { + session.current = beginWorkspaceCreateTelemetry({ + source: request.telemetrySource, + entryPoint: 'runtime', + repoPath, + fromExistingBranch: + typeof request.baseBranch === 'string' && request.baseBranch.length > 0, + isFolder: false + }) + return session.current.timing + } + }) + session.current?.succeeded() + return result + } catch (error) { + session.current?.failed(error) + throw error + } +} + +/** Runs after the create has returned, so the repo probe never adds to create latency. */ +async function trackWorkspaceCreated( + props: EventProps<'workspace_created'>, + repoPath: string, + timing: WorktreeCreateTiming, + context: WorkspaceCreateEventContext +): Promise { + // SSH would need a remote round trip, so only local and WSL repos are probed; with telemetry + // off the repo's .git is not read at all. + const repoFacts = + isTelemetryEnabled() && (timing.executionHost === 'local' || timing.executionHost === 'wsl') + ? await probeCreateEventRepoFacts(repoPath) + : undefined + track('workspace_created', { + ...props, + ...workspaceCreateTimingFields(timing, { ...context, ...(repoFacts ? { repoFacts } : {}) }) + }) +} diff --git a/src/main/worktree-create-concurrency.test.ts b/src/main/worktree-create-concurrency.test.ts new file mode 100644 index 00000000000..0df09b9552d --- /dev/null +++ b/src/main/worktree-create-concurrency.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetWorktreeCreateConcurrencyForTests, + beginPreparationWork, + beginWorktreeCreate, + trackPreparationWork +} from './worktree-create-concurrency' + +describe('worktree create concurrency', () => { + afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + }) + + it('reports zero for a create that ran alone', () => { + expect(beginWorktreeCreate().end()).toEqual({ otherCreates: 0, preparations: 0 }) + }) + + it('reports the most other creates seen at once, including ones that started later', () => { + const first = beginWorktreeCreate() + const second = beginWorktreeCreate() + const third = beginWorktreeCreate() + expect(second.end().otherCreates).toBe(2) + expect(third.end().otherCreates).toBe(2) + // Peak, not count at the end: `first` once overlapped two others. + expect(first.end().otherCreates).toBe(2) + }) + + it('stops counting a create once it ends, and a later end returns the same counts', () => { + const first = beginWorktreeCreate() + const counts = first.end() + beginPreparationWork() + expect(first.end()).toBe(counts) + expect(beginWorktreeCreate().end().otherCreates).toBe(0) + }) + + it('counts preparation work running at the start and work that starts later, as a peak', () => { + const running = beginPreparationWork() + const create = beginWorktreeCreate() + running.end() + const later = beginPreparationWork() + const another = beginPreparationWork() + later.end() + another.end() + expect(create.end().preparations).toBe(2) + }) + + it('leaves out the prepared checkout the create adopted, wherever its peak fell', () => { + const adopted = beginPreparationWork() + const create = beginWorktreeCreate() + const other = beginPreparationWork() + other.end() + create.adoptPreparation(adopted) + adopted.end() + expect(create.end().preparations).toBe(1) + }) + + it('reports zero when the only work seen was the adopted prepared checkout', () => { + const create = beginWorktreeCreate() + const adopted = beginPreparationWork() + create.adoptPreparation(adopted) + expect(create.end().preparations).toBe(0) + }) + + it('still counts work that ran after the adopted prepared checkout finished', () => { + const create = beginWorktreeCreate() + const adopted = beginPreparationWork() + create.adoptPreparation(adopted) + adopted.end() + beginPreparationWork().end() + expect(create.end().preparations).toBe(1) + }) + + it('ignores work that starts after the create ended', () => { + const create = beginWorktreeCreate() + create.end() + beginPreparationWork() + expect(create.end().preparations).toBe(0) + }) + + it('counts tracked work until it settles, including on failure', async () => { + const observer = beginWorktreeCreate() + await trackPreparationWork(Promise.reject(new Error('discard failed'))).catch(() => undefined) + const create = beginWorktreeCreate() + expect(create.end().preparations).toBe(0) + expect(observer.end().preparations).toBe(1) + }) +}) diff --git a/src/main/worktree-create-concurrency.ts b/src/main/worktree-create-concurrency.ts new file mode 100644 index 00000000000..da3b88b7711 --- /dev/null +++ b/src/main/worktree-create-concurrency.ts @@ -0,0 +1,109 @@ +// In-memory counts of worktree creates and prepared-checkout disk work running in this process, so +// a slow create can be read against what competed with it for the disk. + +/** One prepared-checkout build or discard; its identity lets the create that used it leave it out. */ +export type PreparationWork = { end(): void } + +type InFlightCreate = { + peakOtherCreates: number + peakPreparations: number + /** Per work item seen: the peak with that item left out. */ + peakPreparationsWithout: Map + adopted: PreparationWork | undefined +} + +export type WorktreeCreateConcurrency = { + /** Most other creates seen running alongside this one. */ + otherCreates: number + /** Most preparation work seen running at once, excluding the prepared checkout this create used. */ + preparations: number +} + +export type WorktreeCreateInFlightHandle = { + /** Leaves `work` out of this create's count: it is the prepared checkout this create waits on. */ + adoptPreparation(work: PreparationWork): void + /** Ends this create's membership; later calls return the same counts. */ + end(): WorktreeCreateConcurrency +} + +const creates = new Set() +const preparations = new Set() + +function observePreparations(create: InFlightCreate): void { + const count = preparations.size + for (const [work, peak] of create.peakPreparationsWithout) { + create.peakPreparationsWithout.set( + work, + Math.max(peak, count - (preparations.has(work) ? 1 : 0)) + ) + } + for (const work of preparations) { + if (!create.peakPreparationsWithout.has(work)) { + // Before it appeared, leaving it out changed nothing, so the plain peak so far applies. + create.peakPreparationsWithout.set(work, Math.max(create.peakPreparations, count - 1)) + } + } + create.peakPreparations = Math.max(create.peakPreparations, count) +} + +export function beginWorktreeCreate(): WorktreeCreateInFlightHandle { + const entry: InFlightCreate = { + peakOtherCreates: creates.size, + peakPreparations: 0, + peakPreparationsWithout: new Map(), + adopted: undefined + } + for (const other of creates) { + other.peakOtherCreates = Math.max(other.peakOtherCreates, creates.size) + } + observePreparations(entry) + creates.add(entry) + let result: WorktreeCreateConcurrency | undefined + return { + adoptPreparation(work) { + entry.adopted = work + }, + end() { + if (!result) { + creates.delete(entry) + result = { + otherCreates: entry.peakOtherCreates, + preparations: entry.adopted + ? (entry.peakPreparationsWithout.get(entry.adopted) ?? entry.peakPreparations) + : entry.peakPreparations + } + } + return result + } + } +} + +export function beginPreparationWork(): PreparationWork { + let ended = false + const work: PreparationWork = { + end() { + if (ended) { + return + } + ended = true + // Peaks only rise when work starts, so ending needs no observation. + preparations.delete(work) + } + } + preparations.add(work) + for (const create of creates) { + observePreparations(create) + } + return work +} + +/** Counts `operation` as preparation work until it settles. */ +export function trackPreparationWork(operation: Promise): Promise { + const work = beginPreparationWork() + return operation.finally(() => work.end()) +} + +export function _resetWorktreeCreateConcurrencyForTests(): void { + creates.clear() + preparations.clear() +} diff --git a/src/main/worktree-create-preparation-activity.test.ts b/src/main/worktree-create-preparation-activity.test.ts new file mode 100644 index 00000000000..b896767d6b8 --- /dev/null +++ b/src/main/worktree-create-preparation-activity.test.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createPreparationActivity } from './worktree-create-preparation-activity' +import { + _resetWorktreeCreateConcurrencyForTests, + beginWorktreeCreate +} from './worktree-create-concurrency' + +let now = 0 + +beforeEach(() => { + now = 1_000 + vi.spyOn(performance, 'now').mockImplementation(() => now) +}) + +afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + vi.restoreAllMocks() +}) + +describe('createPreparationActivity', () => { + it('reports the origin, including a re-arm the prefetch then asked for', () => { + expect(createPreparationActivity('explicit').origin()).toBe('prefetch') + expect(createPreparationActivity('automatic').origin()).toBe('rearm') + const asked = createPreparationActivity('automatic') + asked.requestedByPrefetch() + expect(asked.origin()).toBe('rearm_then_prefetch') + }) + + it('keeps one piece of work for a tip refresh queued before the build finished', async () => { + const activity = createPreparationActivity('explicit') + const build = Promise.withResolvers() + activity.track(build.promise) + const refresh = Promise.withResolvers() + const chained = build.promise.then(() => refresh.promise) + activity.track(chained) + const observer = beginWorktreeCreate() + + now = 3_000 + build.resolve() + await build.promise + now = 5_000 + refresh.resolve() + await chained + + // Build is the build alone; idle runs from the refreshed checkout's ready. + expect(activity.timesAt(9_000)).toEqual({ buildMs: 2_000, idleMs: 4_000 }) + expect(observer.end().preparations).toBe(1) + }) + + it('keeps the build time of the first build when a later refresh runs as new work', async () => { + const activity = createPreparationActivity('automatic') + const build = Promise.withResolvers() + activity.track(build.promise) + now = 41_000 + build.resolve() + await build.promise + const observer = beginWorktreeCreate() + + now = 101_000 + const refresh = Promise.withResolvers() + activity.track(refresh.promise) + now = 103_000 + refresh.resolve() + await refresh.promise + + // A 40 s build stays 40 s; idle runs from the refresh's ready, not the build's. + expect(activity.timesAt(113_000)).toEqual({ buildMs: 40_000, idleMs: 10_000 }) + expect(observer.end().preparations).toBe(1) + }) + + it('reports no idle time while a refresh is still running at the claim', async () => { + const activity = createPreparationActivity('explicit') + const build = Promise.resolve() + activity.track(build) + now = 2_000 + await build + const refresh = Promise.withResolvers() + activity.track(refresh.promise) + expect(activity.timesAt(5_000)).toEqual({ buildMs: 1_000, idleMs: 0 }) + }) + + it('reports no idle time when the claim came before the work finished', async () => { + const activity = createPreparationActivity('automatic') + const build = Promise.withResolvers() + activity.track(build.promise) + now = 4_000 + build.resolve() + await build.promise + expect(activity.timesAt(2_000)).toEqual({ buildMs: 3_000, idleMs: 0 }) + }) +}) diff --git a/src/main/worktree-create-preparation-activity.ts b/src/main/worktree-create-preparation-activity.ts new file mode 100644 index 00000000000..de17ff6cd32 --- /dev/null +++ b/src/main/worktree-create-preparation-activity.ts @@ -0,0 +1,80 @@ +import type { PreparedCheckoutOrigin } from '../shared/worktree/create-types' +import { beginPreparationWork, type PreparationWork } from './worktree-create-concurrency' + +/** What a create's event reports about the prepared checkout it used: who asked for it, and the + * disk work of its build and tip refreshes, and their timing. Recording only; never gates the pool. */ +export type PreparationActivity = { + /** Counts the latest build or refresh as disk work competing with creates. */ + readonly work: PreparationWork + /** An explicit prefetch asked for this checkout too. */ + requestedByPrefetch(): void + /** Covers `ready`, the checkout's latest build or tip refresh, until it settles. */ + track(ready: Promise): void + origin(): PreparedCheckoutOrigin + /** First build's duration, and how long the checkout sat ready before `claimedAt`. */ + timesAt(claimedAt: number): { buildMs: number; idleMs: number } +} + +export function createPreparationActivity(kind: 'explicit' | 'automatic'): PreparationActivity { + let work = beginPreparationWork() + let workSettled = false + // Build: from the first build's start (including any wait for the base fetch it is built on) + // to its first ready; a later tip refresh does not restart it. + const buildStartedAt = performance.now() + let buildFinishedAt: number | undefined + // Idle: measured from the latest ready, build or refresh, to the claim. + let latestReadyAt: number | undefined + let latest: Promise | undefined + let prefetchRequested = false + + return { + get work() { + return work + }, + requestedByPrefetch() { + prefetchRequested = true + }, + track(ready) { + // A refresh queued after the previous work settled is new disk work. + if (workSettled) { + work = beginPreparationWork() + workSettled = false + } + latest = ready + latestReadyAt = undefined + const current = work + const settle = (succeeded: boolean): void => { + const now = performance.now() + if (succeeded) { + buildFinishedAt ??= now + } + // A refresh chained onto this work extends it; only the latest settle ends it. + if (latest !== ready) { + return + } + if (succeeded) { + latestReadyAt = now + } + workSettled = true + current.end() + } + void ready.then( + () => settle(true), + () => settle(false) + ) + }, + origin() { + if (kind === 'explicit') { + return 'prefetch' + } + return prefetchRequested ? 'rearm_then_prefetch' : 'rearm' + }, + timesAt(claimedAt) { + // A create that waited for the work reports no idle time. + return { + buildMs: Math.max(0, (buildFinishedAt ?? claimedAt) - buildStartedAt), + idleMs: Math.max(0, claimedAt - (latestReadyAt ?? claimedAt)) + } + } + } +} diff --git a/src/main/worktree-create-preparation-claim-registry.ts b/src/main/worktree-create-preparation-claim-registry.ts new file mode 100644 index 00000000000..90067b31764 --- /dev/null +++ b/src/main/worktree-create-preparation-claim-registry.ts @@ -0,0 +1,78 @@ +// Creates that hold a prepared checkout, and the preparations deferred until each one releases it. +import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' +import type { PreparationEntry, StartPreparationArgs } from './worktree-create-preparation-pool' + +export type DeferredPreparation = { + args: StartPreparationArgs + kind: 'explicit' | 'automatic' +} + +export type PreparationClaim = { + entry: PreparationEntry + requestedKey: string + pendingPreparations: Map +} + +const claims = new Set() + +export function registerClaim(entry: PreparationEntry, requestedKey: string): PreparationClaim { + const claim = { entry, requestedKey, pendingPreparations: new Map() } + claims.add(claim) + return claim +} + +export function hasClaims(): boolean { + return claims.size > 0 +} + +export function isClaimed(entry: PreparationEntry): boolean { + return [...claims].some((claim) => claim.entry === entry) +} + +function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined { + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + return [...claims] + .toReversed() + .find((claim) => claim.entry.key === key || claim.requestedKey === key) +} + +/** Preserve one request per canonical key, with explicit prefetch taking precedence. */ +export function deferPreparationForClaim( + args: StartPreparationArgs, + kind: DeferredPreparation['kind'] +): boolean { + const matching = matchingClaim(args) + if (!matching) { + return false + } + const key = preparationEntryKey( + preparationPathKey(args.repoPath), + preparationPathKey(args.workspaceRoot), + args.canonicalBase, + args.options.wslDistro ?? '' + ) + if (kind === 'explicit' || !matching.pendingPreparations.has(key)) { + matching.pendingPreparations.set(key, { args, kind }) + } + return true +} + +/** A second release is inert, including after a test reset. */ +export function releasePreparationClaim(claim: PreparationClaim): { + released: boolean + pendingPreparations: DeferredPreparation[] +} { + if (!claims.delete(claim)) { + return { released: false, pendingPreparations: [] } + } + return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] } +} + +export function resetClaimsForTests(): void { + claims.clear() +} diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index 280be5af895..d84834d3ff6 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -12,6 +12,17 @@ import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { queuePreparedWorktreeTipRefresh } from './worktree-preparation-refresh-queue' import { toHostFilesystemPath } from './host-tree-removal' +import { createPreparationActivity } from './worktree-create-preparation-activity' +import type { PreparationActivity } from './worktree-create-preparation-activity' +import { + deferPreparationForClaim, + hasClaims, + isClaimed, + registerClaim, + resetClaimsForTests, + type DeferredPreparation, + type PreparationClaim +} from './worktree-create-preparation-claim-registry' import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim' import { startStalePreparationCleanup, @@ -25,6 +36,12 @@ import { trackPreparationDiscard } from './worktree-preparation-discard-retry' +export { + releasePreparationClaim, + type DeferredPreparation, + type PreparationClaim +} from './worktree-create-preparation-claim-registry' + export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 @@ -45,6 +62,8 @@ export type PreparationEntry = { expiration: NodeJS.Timeout controller: AbortController checkoutStarted: boolean + /** Origin, disk work and timing reported by the create that uses it. */ + activity: PreparationActivity } export type StartPreparationArgs = { @@ -56,22 +75,11 @@ export type StartPreparationArgs = { beforeMaterialization?: Promise } -export type DeferredPreparation = { - args: StartPreparationArgs - kind: 'explicit' | 'automatic' -} - const preparations = new Map() -export type PreparationClaim = { - entry: PreparationEntry - requestedKey: string - pendingPreparations: Map -} -const claims = new Set() /** A prepared checkout is a create that is either in flight or imminent. */ export function hasPendingPreparations(): boolean { - return preparations.size > 0 || claims.size > 0 || hasPendingStalePreparationCleanup() + return preparations.size > 0 || hasClaims() || hasPendingStalePreparationCleanup() } function discardEntryInBackground(entry: PreparationEntry): void { @@ -142,53 +150,7 @@ export function takePreparation( requestedCanonicalBase, entry.wslDistro ) - const claim = { entry, requestedKey, pendingPreparations: new Map() } - claims.add(claim) - return claim -} - -function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined { - const key = preparationEntryKey( - preparationPathKey(args.repoPath), - preparationPathKey(args.workspaceRoot), - args.canonicalBase, - args.options.wslDistro ?? '' - ) - return [...claims] - .toReversed() - .find((claim) => claim.entry.key === key || claim.requestedKey === key) -} - -/** Preserve one request per canonical key, with explicit prefetch taking precedence. */ -function deferPreparationForClaim( - args: StartPreparationArgs, - kind: DeferredPreparation['kind'] -): boolean { - const matching = matchingClaim(args) - if (!matching) { - return false - } - const key = preparationEntryKey( - preparationPathKey(args.repoPath), - preparationPathKey(args.workspaceRoot), - args.canonicalBase, - args.options.wslDistro ?? '' - ) - if (kind === 'explicit' || !matching.pendingPreparations.has(key)) { - matching.pendingPreparations.set(key, { args, kind }) - } - return true -} - -/** A second release is inert, including after a test reset. */ -export function releasePreparationClaim(claim: PreparationClaim): { - released: boolean - pendingPreparations: DeferredPreparation[] -} { - if (!claims.delete(claim)) { - return { released: false, pendingPreparations: [] } - } - return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] } + return registerClaim(entry, requestedKey) } export function startPreparation( @@ -202,6 +164,9 @@ export function startPreparation( args.options.wslDistro ?? '' ) if (existing) { + if (kind === 'explicit') { + existing.activity.requestedByPrefetch() + } return args.beforeMaterialization ? refreshPreparationTip(existing, args.beforeMaterialization) : existing.ready @@ -209,18 +174,18 @@ export function startPreparation( if (deferPreparationForClaim(args, kind)) { return Promise.resolve() } - return worktreePreparationGit.run(() => startBackgroundPreparation(args)) + return worktreePreparationGit.run(() => startBackgroundPreparation({ ...args, kind })) } function refreshPreparationTip( entry: PreparationEntry, beforeMaterialization: Promise ): Promise { - return queuePreparedWorktreeTipRefresh( + const ready = queuePreparedWorktreeTipRefresh( entry, () => { const available = preparations.get(entry.key) === entry - if (!available && ![...claims].some((claim) => claim.entry === entry)) { + if (!available && !isClaimed(entry)) { return } if (available) { @@ -231,6 +196,8 @@ function refreshPreparationTip( }, beforeMaterialization ) + entry.activity.track(ready) + return ready } function startBackgroundPreparation({ @@ -239,8 +206,9 @@ function startBackgroundPreparation({ baseBranch, canonicalBase, options, - beforeMaterialization -}: StartPreparationArgs): Promise { + beforeMaterialization, + kind +}: StartPreparationArgs & { kind: DeferredPreparation['kind'] }): Promise { const repoPathKey = preparationPathKey(repoPath) const workspaceRootKey = preparationPathKey(workspaceRoot) const wslDistro = options.wslDistro ?? '' @@ -273,6 +241,7 @@ function startBackgroundPreparation({ expiration, controller, checkoutStarted: false, + activity: createPreparationActivity(kind), ready: Promise.resolve() } entry.ready = (async () => { @@ -303,6 +272,7 @@ function startBackgroundPreparation({ } })() preparations.set(key, entry) + entry.activity.track(entry.ready) void entry.ready.catch(() => { if (preparations.get(key) === entry) { preparations.delete(key) @@ -315,7 +285,7 @@ function startBackgroundPreparation({ export async function _resetPreparationPoolForTests(): Promise { const entries = [...preparations.values()] preparations.clear() - claims.clear() + resetClaimsForTests() await resetStalePreparationCleanupForTests() await Promise.all( entries.map(async (entry) => { diff --git a/src/main/worktree-create-preparation-reservation.ts b/src/main/worktree-create-preparation-reservation.ts new file mode 100644 index 00000000000..137e5f31477 --- /dev/null +++ b/src/main/worktree-create-preparation-reservation.ts @@ -0,0 +1,96 @@ +import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' +import type { AddWorktreeOptions } from './git/worktree' +import { measureRetargetDivergence } from './git/worktree-base-divergence' +import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' +import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' +import { + listPreparations, + takePreparation, + type PreparationClaim, + type PreparationEntry +} from './worktree-create-preparation-pool' + +export function canonicalBaseRef( + repoPath: string, + baseBranch: string, + options: AddWorktreeOptions +): Promise { + return resolveLocalWorktreeBaseRef(repoPath, baseBranch, { + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) + }) +} + +export type ReservedPreparation = { + entry: PreparationEntry + reservation: PreparationClaim + retargeted: boolean + canonicalBase: string +} + +/** Selection through take, so the take stays in the same synchronous run as the last selection. */ +export async function reservePreparation( + request: { repoPath: string; workspaceRoot: string; baseBranch: string }, + options: AddWorktreeOptions +): Promise< + | ({ status: 'reserved' } & ReservedPreparation) + | { status: 'miss'; reason: PreparedCheckoutMissReason } +> { + const key = { + repoPathKey: preparationPathKey(request.repoPath), + workspaceRootKey: preparationPathKey(request.workspaceRoot), + wslDistro: options.wslDistro ?? '', + baseBranch: request.baseBranch + } + let selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase: null }) + if (selection.kind === 'needs-canonical-base') { + // The probe is the only await here, and the pool is re-read after it, so the select-and-take + // below stays one synchronous run and no other create can hold the same entry. + const canonicalBase = await canonicalBaseRef(request.repoPath, request.baseBranch, options) + selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase }) + } + if (selection.kind !== 'exact' && selection.kind !== 'retarget') { + return { + status: 'miss', + reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch' + } + } + if (selection.kind === 'retarget') { + const candidate = selection.candidate + const { canonicalBase } = selection + const divergence = await measureRetargetDivergence( + request.repoPath, + candidate.canonicalBase, + canonicalBase, + { + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}), + // Why forward it: a cancelled create must stop these probes now, not at the deadline. + ...(options.signal ? { signal: options.signal } : {}) + } + ) + if (divergence !== 'within') { + return { + status: 'miss', + reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable' + } + } + // Re-select after the walk: the pool may have gained an exact match or lost this entry. A + // different retarget candidate is left for the next create rather than claimed unverified. + selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase }) + if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') { + return { status: 'miss', reason: 'base_mismatch' } + } + if (selection.kind === 'retarget' && selection.candidate !== candidate) { + return { status: 'miss', reason: 'base_mismatch' } + } + } + const entry = selection.candidate + return { + status: 'reserved', + entry, + reservation: takePreparation(entry, selection.canonicalBase), + retargeted: selection.kind === 'retarget', + canonicalBase: selection.canonicalBase + } +} diff --git a/src/main/worktree-create-preparation-timing.test.ts b/src/main/worktree-create-preparation-timing.test.ts index bd78f506201..3de0e64ceab 100644 --- a/src/main/worktree-create-preparation-timing.test.ts +++ b/src/main/worktree-create-preparation-timing.test.ts @@ -6,7 +6,8 @@ const mocks = vi.hoisted(() => ({ prepare: vi.fn(), finalize: vi.fn(), discard: vi.fn(), - listWorktreeGraph: vi.fn() + listWorktreeGraph: vi.fn(), + resolveBaseRef: vi.fn() })) vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) @@ -17,7 +18,9 @@ vi.mock('./git/worktree-create-preparation', () => ({ discardPreparedWorktree: mocks.discard, unlockPreparedWorktree: vi.fn() })) -vi.mock('./git/worktree-base-ref-probe', () => ({ resolveLocalWorktreeBaseRef: vi.fn() })) +vi.mock('./git/worktree-base-ref-probe', () => ({ + resolveLocalWorktreeBaseRef: mocks.resolveBaseRef +})) vi.mock('./git/worktree-base-divergence', () => ({ measureRetargetDivergence: vi.fn() })) vi.mock('./project-runtime-git-options', () => ({ getLocalProjectWorktreeGitOptions: vi.fn(), @@ -34,6 +37,10 @@ import { } from './worktree-create-preparation' import { startPreparation } from './worktree-create-preparation-pool' import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' +import { + _resetWorktreeCreateConcurrencyForTests, + beginWorktreeCreate +} from './worktree-create-concurrency' const request = { repoPath: '/repo', @@ -53,16 +60,23 @@ function prepare() { }) } +/** Lets the claim's own phase settle before the test clock moves on to the wait. */ +function settleClaim(): Promise { + return new Promise((resolve) => setImmediate(resolve)) +} + beforeEach(() => { mocks.mkdir.mockReset().mockResolvedValue(undefined) mocks.prepare.mockReset().mockResolvedValue(undefined) mocks.finalize.mockReset().mockResolvedValue({}) mocks.discard.mockReset().mockResolvedValue(undefined) mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.resolveBaseRef.mockReset() }) afterEach(async () => { await _resetWorktreeCreatePreparationsForTests() + _resetWorktreeCreateConcurrencyForTests() vi.restoreAllMocks() }) @@ -88,17 +102,26 @@ describe('prepared checkout create timing', () => { const create = timing.time('git_worktree_add', () => consumePreparedWorktreeCreate({ ...request, timing }) ) + await settleClaim() now = 150 checkout.resolve() await finalizeStarted.promise now = 180 finalize.resolve({}) - expect(await create).toMatchObject({ status: 'hit', retargeted: false }) + expect(await create).toMatchObject({ status: 'hit', retargeted: false, reset: 'none' }) await preparation expect(timing.finish()).toEqual({ totalDurationMs: 140, + preparedCheckout: { + status: 'hit', + reset: 'none', + origin: 'prefetch', + buildMs: expect.any(Number), + idleMs: 0 + }, phases: [ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }, { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 110 }, { phase: 'prepared_checkout_finalize', startedAtMs: 110, durationMs: 30 }, { phase: 'git_worktree_add', startedAtMs: 0, durationMs: 140 } @@ -118,6 +141,7 @@ describe('prepared checkout create timing', () => { let now = 0 const timing = createWorktreeCreateTimingRecorder(() => now) const create = consumePreparedWorktreeCreate({ ...request, timing }) + await settleClaim() now = 250 checkout.reject(new Error('checkout failed')) @@ -128,12 +152,13 @@ describe('prepared checkout create timing', () => { }) await preparation expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }, { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 250 } ]) expect(mocks.finalize).not.toHaveBeenCalled() }) - it('records a failed finalization before its fallback cleanup', async () => { + it('times the cleanup of a failed finalization apart from the finalization', async () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) await prepare() let now = 0 @@ -152,17 +177,152 @@ describe('prepared checkout create timing', () => { rearm: expect.any(Function) }) expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }, { phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 0 }, - { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 } + { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 }, + { phase: 'prepared_checkout_discard', startedAtMs: 80, durationMs: 20 } ]) }) - it('does not report a preparation wait when no checkout was armed', async () => { + it('reports only the claim, and the miss, when no checkout was armed', async () => { const timing = createWorktreeCreateTimingRecorder(() => 0) expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({ status: 'miss', reason: 'none_armed' }) - expect(timing.finish().phases).toEqual([]) + expect(timing.finish()).toMatchObject({ + phases: [{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }], + preparedCheckout: { status: 'miss', reason: 'none_armed' } + }) + }) + + it('times a miss probe as the claim, not as the plain add after it', async () => { + await startPreparation({ + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: 'origin/release', + canonicalBase: 'refs/remotes/origin/release', + options: {} + }) + let now = 0 + const timing = createWorktreeCreateTimingRecorder(() => now) + mocks.resolveBaseRef.mockImplementation(async () => { + now = 400 + return 'refs/remotes/origin/main' + }) + + const attempt = await timing.time('git_worktree_add', async () => { + const result = await consumePreparedWorktreeCreate({ ...request, timing }) + now = 500 + return result + }) + + expect(attempt).toMatchObject({ status: 'miss', reason: 'base_mismatch' }) + expect(timing.finish().phases).toEqual([ + { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 400 }, + { phase: 'git_worktree_add', startedAtMs: 0, durationMs: 500 } + ]) + }) + + it('reports the reset a hit needed and who armed it', async () => { + await startPreparation( + { + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: request.baseBranch, + canonicalBase: 'refs/remotes/origin/main', + options: {} + }, + 'automatic' + ) + mocks.finalize.mockResolvedValue({ preparedHeadReset: true }) + const timing = createWorktreeCreateTimingRecorder(() => 0) + + const attempt = await consumePreparedWorktreeCreate({ ...request, timing }) + + expect(attempt).toMatchObject({ status: 'hit', reset: 'base_moved', origin: 'rearm' }) + expect(attempt.status === 'hit' && attempt.result).toEqual({}) + expect(timing.finish().preparedCheckout).toMatchObject({ + status: 'hit', + reset: 'base_moved', + origin: 'rearm' + }) + }) + + it('reports a re-arm the new-worktree UI then asked for as well', async () => { + const args = { + repoPath: request.repoPath, + workspaceRoot: request.workspaceRoot, + baseBranch: request.baseBranch, + canonicalBase: 'refs/remotes/origin/main', + options: {} + } + await startPreparation(args, 'automatic') + await startPreparation(args, 'explicit') + expect(mocks.prepare).toHaveBeenCalledOnce() + + const attempt = await consumePreparedWorktreeCreate({ ...request }) + + expect(attempt).toMatchObject({ status: 'hit', origin: 'rearm_then_prefetch' }) + }) + + it('reports how long the build took and how long it sat ready before the claim', async () => { + let now = 1_000 + vi.spyOn(performance, 'now').mockImplementation(() => now) + const checkout = Promise.withResolvers() + mocks.prepare.mockReturnValue(checkout.promise) + const preparation = prepare() + now = 4_000 + checkout.resolve() + await preparation + now = 9_000 + + const attempt = await consumePreparedWorktreeCreate({ ...request }) + + expect(attempt).toMatchObject({ status: 'hit', buildMs: 3_000, idleMs: 5_000 }) + }) + + it('reports no idle time when the create waited for the build', async () => { + let now = 1_000 + vi.spyOn(performance, 'now').mockImplementation(() => now) + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + const preparation = prepare() + await checkoutStarted.promise + now = 2_000 + const create = consumePreparedWorktreeCreate({ ...request }) + await settleClaim() + now = 6_000 + checkout.resolve() + await preparation + + expect(await create).toMatchObject({ status: 'hit', buildMs: 5_000, idleMs: 0 }) + }) + + it('counts a building prepared checkout against other creates but not the one that used it', async () => { + const checkoutStarted = Promise.withResolvers() + const checkout = Promise.withResolvers() + mocks.prepare.mockImplementation(() => { + checkoutStarted.resolve() + return checkout.promise + }) + const bystander = beginWorktreeCreate() + const preparation = prepare() + await checkoutStarted.promise + const inFlight = beginWorktreeCreate() + const timing = createWorktreeCreateTimingRecorder(undefined, inFlight) + + const create = consumePreparedWorktreeCreate({ ...request, timing }) + await settleClaim() + checkout.resolve() + await preparation + + expect(await create).toMatchObject({ status: 'hit', origin: 'prefetch' }) + expect(inFlight.end().preparations).toBe(0) + expect(bystander.end().preparations).toBe(1) }) }) diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts index 052181da228..cff625c14b1 100644 --- a/src/main/worktree-create-preparation.test.ts +++ b/src/main/worktree-create-preparation.test.ts @@ -131,6 +131,11 @@ describe('worktree create preparation registry', () => { ).resolves.toEqual({ status: 'hit', retargeted: true, + // The mocked finalize found the checkout already at the requested commit. + reset: 'none', + buildMs: expect.any(Number), + idleMs: expect.any(Number), + origin: 'prefetch', result: {}, rearm: expect.any(Function) }) @@ -213,6 +218,10 @@ describe('worktree create preparation registry', () => { ).resolves.toEqual({ status: 'hit', retargeted: false, + reset: 'none', + buildMs: expect.any(Number), + idleMs: expect.any(Number), + origin: 'prefetch', result: {}, rearm: expect.any(Function) }) @@ -543,6 +552,11 @@ describe('worktree create preparation registry', () => { ).resolves.toEqual({ status: 'hit', retargeted: false, + reset: 'none', + buildMs: expect.any(Number), + idleMs: expect.any(Number), + // Built by the burst re-arm after the second create; the prefetch after it asked for it too. + origin: 'rearm_then_prefetch', result: {}, rearm: expect.any(Function) }) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index 0bca1160d8e..5ca98be283b 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -5,23 +5,28 @@ import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { isFolderRepo } from '../shared/repo-kind' import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' -import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types' +import type { + PreparedCheckoutMissReason, + PreparedCheckoutOrigin, + PreparedCheckoutReset +} from '../shared/worktree/create-types' +import type { WorktreeCreatePhase } from '../shared/worktree/create-timing-vocabulary' import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' -import { measureRetargetDivergence } from './git/worktree-base-divergence' -import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' -import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim' import { _resetPreparationPoolForTests, hasPendingPreparations, - listPreparations, releasePreparationClaim, startPreparation, - takePreparation, type DeferredPreparation, type PreparationClaim, type PreparationEntry } from './worktree-create-preparation-pool' +import { + canonicalBaseRef, + reservePreparation, + type ReservedPreparation +} from './worktree-create-preparation-reservation' import { discardPreparedWorktree, finalizePreparedWorktree @@ -56,6 +61,10 @@ export type PreparedWorktreeCreateAttempt = | { status: 'hit' retargeted: boolean + reset: PreparedCheckoutReset + origin: PreparedCheckoutOrigin + buildMs: number + idleMs: number result: AddWorktreeResult /** Run after materialization/startup completes, before returning the create result. */ rearm: () => void @@ -70,18 +79,18 @@ type ConsumePreparedWorktreeArgs = { baseBranch: string refreshLocalBaseRef?: boolean options?: AddWorktreeOptions - timing?: Pick + timing?: Pick< + WorktreeCreateTimingRecorder, + 'time' | 'recordPreparedCheckout' | 'recordAdoptedPreparation' + > } -function canonicalBaseRef( - repoPath: string, - baseBranch: string, - options: AddWorktreeOptions -): Promise { - return resolveLocalWorktreeBaseRef(repoPath, baseBranch, { - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}) - }) +function timePhase( + args: ConsumePreparedWorktreeArgs, + phase: WorktreeCreatePhase, + operation: () => Promise +): Promise { + return args.timing ? args.timing.time(phase, operation) : operation() } export function prepareWorktreeCreateForRepo( @@ -125,84 +134,26 @@ async function prepareWorktreeCreateInBackground( } type ClaimedPreparation = - | { - status: 'claimed' - entry: PreparationEntry - reservation: PreparationClaim - retargeted: boolean - canonicalBase: string - } + | ({ status: 'claimed'; claimedAt: number } & ReservedPreparation) | { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void } async function claimPreparedWorktree( args: ConsumePreparedWorktreeArgs, options: AddWorktreeOptions ): Promise { - const request = { - repoPathKey: preparationPathKey(args.repoPath), - workspaceRootKey: preparationPathKey(args.workspaceRoot), - wslDistro: options.wslDistro ?? '', - baseBranch: args.baseBranch + // Timed on its own so a miss's probes and drift check are not read as the plain add's cost. + const reserved = await timePhase(args, 'prepared_checkout_claim', () => + reservePreparation(args, options) + ) + if (reserved.status === 'miss') { + return reserved } - let selection = selectPreparationForCreate(listPreparations(), { - ...request, - canonicalBase: null - }) - if (selection.kind === 'needs-canonical-base') { - // The probe is the only await here, and the pool is re-read after it, so the select-and-take - // below stays one synchronous run and no other create can hold the same entry. - const canonicalBase = await canonicalBaseRef(args.repoPath, args.baseBranch, options) - selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) - } - if (selection.kind !== 'exact' && selection.kind !== 'retarget') { - return { - status: 'miss', - reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch' - } - } - if (selection.kind === 'retarget') { - const candidate = selection.candidate - const { canonicalBase } = selection - const divergence = await measureRetargetDivergence( - args.repoPath, - candidate.canonicalBase, - canonicalBase, - { - ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), - ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}), - // Why forward it: a cancelled create must stop these probes now, not at the deadline. - ...(options.signal ? { signal: options.signal } : {}) - } - ) - if (divergence !== 'within') { - return { - status: 'miss', - reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable' - } - } - // Re-select after the walk: the pool may have gained an exact match or lost this entry. A - // different retarget candidate is left for the next create rather than claimed unverified. - selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase }) - if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') { - return { status: 'miss', reason: 'base_mismatch' } - } - if (selection.kind === 'retarget' && selection.candidate !== candidate) { - return { status: 'miss', reason: 'base_mismatch' } - } - } - const entry = selection.candidate - const reservation = takePreparation(entry, selection.canonicalBase) + const { entry, reservation } = reserved + args.timing?.recordAdoptedPreparation(entry.activity.work) + const claimedAt = performance.now() try { - await (args.timing - ? args.timing.time('prepared_checkout_wait', () => entry.ready) - : entry.ready) - return { - status: 'claimed', - entry, - reservation, - retargeted: selection.kind === 'retarget', - canonicalBase: selection.canonicalBase - } + await timePhase(args, 'prepared_checkout_wait', () => entry.ready) + return { ...reserved, status: 'claimed', claimedAt } } catch { return { status: 'miss', reason: 'prepare_failed', rearm: releaseClaimAfterCreate(reservation) } } @@ -267,6 +218,31 @@ function deferRearmPreparation( export async function consumePreparedWorktreeCreate( args: ConsumePreparedWorktreeArgs +): Promise { + const attempt = await attemptPreparedWorktreeCreate(args) + args.timing?.recordPreparedCheckout( + attempt.status === 'hit' + ? { + status: 'hit', + reset: attempt.reset, + origin: attempt.origin, + buildMs: attempt.buildMs, + idleMs: attempt.idleMs + } + : { status: 'miss', reason: attempt.reason } + ) + return attempt +} + +function preparedCheckoutReset(retargeted: boolean, headReset: boolean): PreparedCheckoutReset { + if (!headReset) { + return 'none' + } + return retargeted ? 'retargeted' : 'base_moved' +} + +async function attemptPreparedWorktreeCreate( + args: ConsumePreparedWorktreeArgs ): Promise { const options = args.options ?? {} const claim = await claimPreparedWorktree(args, options) @@ -275,37 +251,45 @@ export async function consumePreparedWorktreeCreate( } const { entry, reservation } = claim try { - const parentDir = isWindowsAbsolutePathLike(args.worktreePath) - ? win32.dirname(args.worktreePath) - : posix.dirname(args.worktreePath) - await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) // Finalize resolves the requested base itself and resets the prepared checkout onto that // commit, so a retargeted claim is handed over at the requested commit or not at all. - const finalize = (): Promise => - finalizePreparedWorktree( - args.repoPath, - entry.preparedPath, - args.worktreePath, - args.branch, - args.baseBranch, - args.refreshLocalBaseRef, - options, - entry.lockReason - ) - const result = args.timing - ? await args.timing.time('prepared_checkout_finalize', finalize) - : await finalize() + const { preparedHeadReset, ...result } = await timePhase( + args, + 'prepared_checkout_finalize', + async () => { + const parentDir = isWindowsAbsolutePathLike(args.worktreePath) + ? win32.dirname(args.worktreePath) + : posix.dirname(args.worktreePath) + await mkdir(toHostFilesystemPath(parentDir), { recursive: true }) + return finalizePreparedWorktree( + args.repoPath, + entry.preparedPath, + args.worktreePath, + args.branch, + args.baseBranch, + args.refreshLocalBaseRef, + options, + entry.lockReason + ) + } + ) // Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is // creating in a burst; the TTL and the preparation limit still bound an unused replacement. const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase) - return { status: 'hit', retargeted: claim.retargeted, result, rearm } + return { + status: 'hit', + retargeted: claim.retargeted, + reset: preparedCheckoutReset(claim.retargeted, preparedHeadReset), + origin: entry.activity.origin(), + ...entry.activity.timesAt(claim.claimedAt), + result, + rearm + } } catch (error) { + // Another owner holds the preparation's lock, so it is not ours to remove. if (!(error instanceof WorktreePreparationLockOwnershipError)) { - await discardPreparedWorktree( - args.repoPath, - entry.preparedPath, - options, - entry.lockReason + await timePhase(args, 'prepared_checkout_discard', () => + discardPreparedWorktree(args.repoPath, entry.preparedPath, options, entry.lockReason) ).catch(() => {}) } console.warn( diff --git a/src/main/worktree-create-timing.test.ts b/src/main/worktree-create-timing.test.ts index 6ef4d23ff42..5fbb0663f4e 100644 --- a/src/main/worktree-create-timing.test.ts +++ b/src/main/worktree-create-timing.test.ts @@ -1,12 +1,17 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { createWorktreeCreateTimingRecorder } from './worktree-create-timing' +import { + _resetWorktreeCreateConcurrencyForTests, + beginPreparationWork, + beginWorktreeCreate +} from './worktree-create-concurrency' describe('createWorktreeCreateTimingRecorder', () => { it('records ordered phase timings and total duration', async () => { const samples = [100, 105, 112, 130, 144, 155] const recorder = createWorktreeCreateTimingRecorder(() => samples.shift() ?? 155) - const syncResult = recorder.timeSync('resolve_branch', () => 'branch') + const syncResult = recorder.timeSync('resolve_name', () => 'branch') const asyncResult = await recorder.time('git_worktree_add', async () => 'created') expect(syncResult).toBe('branch') @@ -14,9 +19,166 @@ describe('createWorktreeCreateTimingRecorder', () => { expect(recorder.finish()).toEqual({ totalDurationMs: 55, phases: [ - { phase: 'resolve_branch', startedAtMs: 5, durationMs: 7 }, + { phase: 'resolve_name', startedAtMs: 5, durationMs: 7 }, { phase: 'git_worktree_add', startedAtMs: 30, durationMs: 14 } ] }) }) + + it('carries the execution host and worktree count only once recorded', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + expect(recorder.finish()).not.toHaveProperty('executionHost') + expect(recorder.finish()).not.toHaveProperty('worktreeCount') + + recorder.recordExecutionHost('local') + recorder.recordExecutionHost('wsl') + recorder.recordWorktreeCount(0) + + expect(recorder.finish()).toMatchObject({ executionHost: 'wsl', worktreeCount: 0 }) + }) + + describe('failedPhase', () => { + function captureRejection(promise: Promise): Promise { + return promise.then( + () => { + throw new Error('expected a rejection') + }, + (error: unknown) => error + ) + } + + it('names the phase whose operation threw', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + await recorder.time('resolve_name', async () => undefined) + const error = await captureRejection( + recorder.time('git_worktree_add', async () => { + throw new Error('boom') + }) + ) + + expect(recorder.failedPhase(error)).toBe('git_worktree_add') + // The phase that threw is still timed. + expect(recorder.finish().phases.map((phase) => phase.phase)).toEqual([ + 'resolve_name', + 'git_worktree_add' + ]) + }) + + it('is undefined for an error no phase threw', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + await recorder.time('git_worktree_add', async () => undefined) + expect(recorder.failedPhase(new Error('outside'))).toBeUndefined() + expect(recorder.failedPhase('not an object')).toBeUndefined() + }) + + it('names the outermost phase an error propagated through', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + const error = await captureRejection( + recorder.time('git_worktree_add', () => + recorder.time('prepared_checkout_wait', async () => { + throw new Error('prepare failed') + }) + ) + ) + + expect(recorder.failedPhase(error)).toBe('git_worktree_add') + }) + + it('reports untimed for a later throw after an inner failure was caught', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + await recorder.time('git_worktree_add', async () => { + await recorder + .time('prepared_checkout_wait', async () => { + throw new Error('prepare failed') + }) + .catch(() => undefined) + }) + + expect(recorder.failedPhase(new Error('later, outside every phase'))).toBeUndefined() + }) + + it('names the rejecting phase when a concurrent sibling settles later', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + let resolveSibling: () => void = () => {} + const sibling = recorder.time( + 'resolve_worktreeinclude', + () => + new Promise((resolve) => { + resolveSibling = resolve + }) + ) + const error = await captureRejection( + Promise.all([ + recorder.time('resolve_shared_directories', async () => { + throw new Error('shared dirs failed') + }), + sibling + ]) + ) + resolveSibling() + await sibling + + expect(recorder.failedPhase(error)).toBe('resolve_shared_directories') + }) + + it('follows the cause chain of an error wrapped outside the phase', async () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + const inner = await captureRejection( + recorder.time('git_worktree_add', async () => { + throw new Error('old relay') + }) + ) + + expect(recorder.failedPhase(new Error('wrapped', { cause: inner }))).toBe('git_worktree_add') + }) + + it('stops walking a cyclic cause chain', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + const first = new Error('first') + const second = new Error('second', { cause: first }) + first.cause = second + + expect(recorder.failedPhase(first)).toBeUndefined() + }) + + it('names a sync phase that threw', () => { + const recorder = createWorktreeCreateTimingRecorder(() => 0) + let error: unknown + try { + recorder.timeSync('persist_metadata', () => { + throw new Error('disk full') + }) + } catch (caught) { + error = caught + } + + expect(recorder.failedPhase(error)).toBe('persist_metadata') + }) + }) + + describe('concurrency window', () => { + afterEach(() => { + _resetWorktreeCreateConcurrencyForTests() + }) + + it('closes at finish, so work the create starts afterwards is not counted', () => { + const inFlight = beginWorktreeCreate() + const recorder = createWorktreeCreateTimingRecorder(() => 0, inFlight) + const during = beginPreparationWork() + during.end() + recorder.finish() + beginPreparationWork() + beginPreparationWork() + expect(inFlight.end().preparations).toBe(1) + }) + + it('leaves out the prepared checkout the create adopted', () => { + const adopted = beginPreparationWork() + const inFlight = beginWorktreeCreate() + const recorder = createWorktreeCreateTimingRecorder(() => 0, inFlight) + recorder.recordAdoptedPreparation(adopted) + recorder.finish() + expect(inFlight.end().preparations).toBe(0) + }) + }) }) diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts index bc1e49f4842..d04fb6640e8 100644 --- a/src/main/worktree-create-timing.ts +++ b/src/main/worktree-create-timing.ts @@ -1,18 +1,41 @@ import type { PreparedCheckoutOutcome, + WorktreeCreateExecutionHost, WorktreeCreateTiming, WorktreeCreateTimingPhase } from '../shared/worktree/create-types' +import type { WorktreeCreatePhase } from '../shared/worktree/create-timing-vocabulary' +import type { PreparationWork, WorktreeCreateInFlightHandle } from './worktree-create-concurrency' +import { wslDistroForCommand } from './git/command-runner/git-command-resolution' type TimingClock = () => number +const MAX_CAUSE_DEPTH = 5 + export type WorktreeCreateTimingRecorder = { - time(phase: string, operation: () => Promise): Promise - timeSync(phase: string, operation: () => T): T + time(phase: WorktreeCreatePhase, operation: () => Promise): Promise + timeSync(phase: WorktreeCreatePhase, operation: () => T): T recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void + /** The prepared checkout this create claimed, so its build is not counted as competing work. */ + recordAdoptedPreparation(work: PreparationWork): void + recordExecutionHost(host: WorktreeCreateExecutionHost): void + recordWorktreeCount(count: number): void + /** The outermost phase this error (or one in its cause chain) propagated out of; undefined when none did. */ + failedPhase(error: unknown): WorktreeCreatePhase | undefined + /** Also closes the concurrency window, so work this create starts afterwards (its own re-arm) + * is not counted against it. */ finish(): WorktreeCreateTiming } +/** A local repo's create host by the Git routing rule: a \\wsl.localhost repo runs Git in WSL even + * without a WSL project runtime. */ +export function localWorktreeCreateExecutionHost(gitExecOptions: { + cwd?: string + wslDistro?: string +}): WorktreeCreateExecutionHost { + return wslDistroForCommand(gitExecOptions.cwd, gitExecOptions.wslDistro) ? 'wsl' : 'local' +} + function defaultClock(): number { return performance.now() } @@ -22,7 +45,7 @@ function clampDuration(value: number): number { } function createPhase( - phase: string, + phase: WorktreeCreatePhase, operationStartedAt: number, operationEndedAt: number, rootStartedAt: number @@ -35,29 +58,46 @@ function createPhase( } export function createWorktreeCreateTimingRecorder( - clock: TimingClock = defaultClock + clock: TimingClock = defaultClock, + inFlight?: WorktreeCreateInFlightHandle ): WorktreeCreateTimingRecorder { const startedAt = clock() const phases: WorktreeCreateTimingPhase[] = [] let preparedCheckout: PreparedCheckoutOutcome | undefined + let executionHost: WorktreeCreateExecutionHost | undefined + let worktreeCount: number | undefined + // Keyed by the thrown value, so a caught failure or a concurrent sibling cannot be misattributed. + const phaseByError = new WeakMap() - const recordPhase = (phase: string, operationStartedAt: number): void => { + const recordPhase = (phase: WorktreeCreatePhase, operationStartedAt: number): void => { phases.push(createPhase(phase, operationStartedAt, clock(), startedAt)) } + const recordFailure = (phase: WorktreeCreatePhase, error: unknown): void => { + // Outer phases settle after inner ones, so overwriting leaves the outermost that rethrew. + if (typeof error === 'object' && error !== null) { + phaseByError.set(error, phase) + } + } return { - async time(phase: string, operation: () => Promise): Promise { + async time(phase: WorktreeCreatePhase, operation: () => Promise): Promise { const operationStartedAt = clock() try { return await operation() + } catch (error) { + recordFailure(phase, error) + throw error } finally { recordPhase(phase, operationStartedAt) } }, - timeSync(phase: string, operation: () => T): T { + timeSync(phase: WorktreeCreatePhase, operation: () => T): T { const operationStartedAt = clock() try { return operation() + } catch (error) { + recordFailure(phase, error) + throw error } finally { recordPhase(phase, operationStartedAt) } @@ -65,11 +105,38 @@ export function createWorktreeCreateTimingRecorder( recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void { preparedCheckout = outcome }, + recordAdoptedPreparation(work: PreparationWork): void { + inFlight?.adoptPreparation(work) + }, + recordExecutionHost(host: WorktreeCreateExecutionHost): void { + executionHost = host + }, + recordWorktreeCount(count: number): void { + worktreeCount = count + }, + failedPhase(error: unknown) { + // Bounded so a cyclic cause chain cannot loop. + let current = error + for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth += 1) { + if (typeof current !== 'object' || current === null) { + return undefined + } + const phase = phaseByError.get(current) + if (phase) { + return phase + } + current = current instanceof Error ? current.cause : undefined + } + return undefined + }, finish() { + inFlight?.end() return { totalDurationMs: clampDuration(clock() - startedAt), phases: [...phases], - ...(preparedCheckout ? { preparedCheckout } : {}) + ...(preparedCheckout ? { preparedCheckout } : {}), + ...(executionHost ? { executionHost } : {}), + ...(worktreeCount !== undefined ? { worktreeCount } : {}) } } } diff --git a/src/main/worktree-preparation-discard-retry.ts b/src/main/worktree-preparation-discard-retry.ts index 69ea2c4edd3..c84895d18f3 100644 --- a/src/main/worktree-preparation-discard-retry.ts +++ b/src/main/worktree-preparation-discard-retry.ts @@ -2,6 +2,7 @@ import type { AddWorktreeOptions } from './git/worktree' import { discardPreparedWorktree } from './git/worktree-create-preparation' import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { isOrphanedWorktreeError } from './ipc/worktree-logic' +import { trackPreparationWork } from './worktree-create-concurrency' import type { PreparationEntry } from './worktree-create-preparation-pool' // Stale cleanup only reclaims preparations whose owner pid is dead, so a discard that fails inside @@ -85,7 +86,8 @@ export function discardPreparationWithRetry( ): Promise { // Claim the record so an overlapping retry pass cannot run the same discard twice. pendingDiscards.delete(pendingKey(target)) - const discard = runDiscard(target, attempts) + // Counted as preparation work: removing a full tree competes with creates for the disk. + const discard = trackPreparationWork(runDiscard(target, attempts)) trackPreparationDiscard(discard) return discard } diff --git a/src/main/worktree-preparation-tip-refresh.test.ts b/src/main/worktree-preparation-tip-refresh.test.ts index cc22314a8b8..3604d04fb57 100644 --- a/src/main/worktree-preparation-tip-refresh.test.ts +++ b/src/main/worktree-preparation-tip-refresh.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { flushBackgroundWork, mocks, repo, store } from './__mocks__/worktree-create-preparation' import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock' import { @@ -7,6 +7,7 @@ import { } from './worktree-create-preparation' import { listPreparations, + startPreparation, WORKTREE_CREATE_PREPARATION_TTL_MS } from './worktree-create-preparation-pool' @@ -223,3 +224,44 @@ describe('prepared checkout tip refresh', () => { } }) }) + +describe('prepared checkout timing across a tip refresh', () => { + it('keeps the re-arm build time and measures idle time from the refreshed ready', async () => { + let now = 0 + const clock = vi.spyOn(performance, 'now').mockImplementation(() => now) + onTestFinished(() => clock.mockRestore()) + const build = Promise.withResolvers() + mocks.prepareCheckout.mockReturnValueOnce(build.promise) + const armed = startPreparation( + { + repoPath: repo.path, + workspaceRoot: '/workspace', + baseBranch: 'origin/main', + canonicalBase: 'refs/remotes/origin/main', + options: {} + }, + 'automatic' + ) + await vi.waitFor(() => expect(mocks.prepareCheckout).toHaveBeenCalled()) + now = 40_000 + build.resolve() + await armed + + // The dialog opens a minute later and refreshes the spare once a 2 s fetch settles. + now = 100_000 + const fetch = Promise.withResolvers() + const refreshed = prepareWorktreeCreateForRepo(store, repo, 'origin/main', fetch.promise) + await flushBackgroundWork() + now = 102_000 + fetch.resolve() + await refreshed + now = 112_000 + + await expect(consume()).resolves.toMatchObject({ + status: 'hit', + origin: 'rearm_then_prefetch', + buildMs: 40_000, + idleMs: 10_000 + }) + }) +}) diff --git a/src/preload/api/pty-api.ts b/src/preload/api/pty-api.ts index c7c46c7b004..4cfb5572776 100644 --- a/src/preload/api/pty-api.ts +++ b/src/preload/api/pty-api.ts @@ -18,6 +18,7 @@ import type { TerminalViewAttributes } from '../../shared/terminal-view-attribut import type { TuiAgent } from '../../shared/tui-agent' import type { PtyManagementApi } from './pty-management-api' import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' +import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command' export type PtyApi = { spawn: (opts: { @@ -126,8 +127,8 @@ export type PtyApi = { } ) => Promise confirmForegroundProcess: (id: string) => Promise - /** Local panes only; false for any other pane. */ - isCodexOnSharedServer: (id: string) => Promise + /** Local panes only; never joined for any other pane. */ + isCodexOnSharedServer: (id: string) => Promise /** Runs the fix with the pane's own Codex; true only once verified. Local panes only. */ disableCodexSharedServerAutoStart: (id: string) => Promise stopCodexSharedServer: (id: string) => Promise diff --git a/src/preload/api/pty-bridge-stream-and-serialization.ts b/src/preload/api/pty-bridge-stream-and-serialization.ts index ec5b232acb5..e0b2e7f2bf6 100644 --- a/src/preload/api/pty-bridge-stream-and-serialization.ts +++ b/src/preload/api/pty-bridge-stream-and-serialization.ts @@ -3,6 +3,7 @@ import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore- import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import type { PreloadApi } from '../api-types' import type { TerminalProcessInspection } from '../../shared/terminal-process-inspection' +import type { CodexSharedServerStatus } from '../../shared/codex-shared-server-command' export const ptyStreamAndSerializationApi = { inspectProcess: ( @@ -16,7 +17,7 @@ export const ptyStreamAndSerializationApi = { ipcRenderer.invoke('pty:inspectProcess', { id, ...options }), confirmForegroundProcess: (id: string): Promise => ipcRenderer.invoke('pty:confirmForegroundProcess', { id }), - isCodexOnSharedServer: (id: string): Promise => + isCodexOnSharedServer: (id: string): Promise => ipcRenderer.invoke('pty:isCodexOnSharedServer', { id }), disableCodexSharedServerAutoStart: (id: string): Promise => ipcRenderer.invoke('pty:disableCodexSharedServerAutoStart', { id }), diff --git a/src/relay/relay-filesystem-watch-registry.test.ts b/src/relay/relay-filesystem-watch-registry.test.ts index dcea47e7d44..a8018bad37c 100644 --- a/src/relay/relay-filesystem-watch-registry.test.ts +++ b/src/relay/relay-filesystem-watch-registry.test.ts @@ -138,20 +138,21 @@ describe('RelayFilesystemWatchRegistry', () => { ]) }) - it('moves a terminal shard failure into recovery without dropping shared clients', async () => { + it.each([ + ['native watcher failure', new Error('native subscription stopped')], + [ + 'watcher process failure', + new WatcherProcessFailure('crashed repeatedly', 'supervisor', 'supervisor_crash_fuse') + ] + ])('recovers after a %s without dropping shared clients', async (_kind, error) => { await registry.watch('/repo', context(1)) await registry.watch('/repo', context(2)) const first = pool.installed[0] - first.hooks.onTerminalError?.( - new WatcherProcessFailure( - 'file watcher process crashed repeatedly', - 'supervisor', - 'supervisor_crash_fuse' - ) - ) + first.hooks.onTerminalError?.(error) await Promise.resolve() expect(pool.installed).toHaveLength(2) + first.callback(null, [{ type: 'create', path: '/repo/late.txt' }]) pool.installed[1].callback(null, [{ type: 'create', path: '/repo/recovered.txt' }]) expect(dispatcher.fsChanged).toEqual([ { events: [{ kind: 'overflow', absolutePath: '/repo' }] }, diff --git a/src/renderer/src/components/CodexRestartChip.tsx b/src/renderer/src/components/CodexRestartChip.tsx index 09835b71302..cc90244579e 100644 --- a/src/renderer/src/components/CodexRestartChip.tsx +++ b/src/renderer/src/components/CodexRestartChip.tsx @@ -20,10 +20,12 @@ type RestartNotice = { export default function CodexRestartChip({ isVisible = true, ptyId, + onReturnFocus, shouldFocus = false }: { isVisible?: boolean ptyId: string + onReturnFocus: () => void shouldFocus?: boolean }): React.JSX.Element | null { // Why: one O(1) selector per mounted pane stays idle when unrelated PTY maps @@ -35,10 +37,12 @@ export default function CodexRestartChip({ const handleRestart = (): void => { useAppStore.getState().queueCodexPaneRestarts([ptyId]) + onReturnFocus() } const handleDismiss = (): void => { useAppStore.getState().dismissCodexRestartNotices([ptyId]) + onReturnFocus() // Why: notices are renderer-only, so the persisted launch record must be // cleared for this pane or the startup sweep re-raises its answered prompt. void window.api.codexAccounts.forgetStalePanes({ ptyIds: [ptyId] }).catch((err: unknown) => { @@ -106,7 +110,19 @@ function LoudRestartOverlay({ aria-live="assertive" aria-labelledby={titleId} aria-describedby={bodyId} - className="pointer-events-none absolute inset-0 z-50 flex items-center justify-center p-6 outline-none" + onMouseDown={(event) => { + if (event.button === 0 && event.target === event.currentTarget) { + onDismiss() + } + }} + onKeyDown={(event) => { + if (event.key === 'Escape') { + event.preventDefault() + event.stopPropagation() + onDismiss() + } + }} + className="absolute inset-0 z-50 flex items-center justify-center p-6 outline-none" >

diff --git a/src/renderer/src/components/activity/activity-thread-actions.ts b/src/renderer/src/components/activity/activity-thread-actions.ts index c2d99c23fc2..84d9d55ea21 100644 --- a/src/renderer/src/components/activity/activity-thread-actions.ts +++ b/src/renderer/src/components/activity/activity-thread-actions.ts @@ -1,6 +1,5 @@ import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane' -import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' -import { isFloatingWorkspacePanelVisible } from '@/lib/floating-workspace-terminal-actions' +import { revealFloatingWorkspacePanel } from '@/lib/floating-workspace-panel-reveal' import { activateStructuredAgentSessionTab } from '@/lib/structured-agent-session-tab-activation' import { activateAndRevealWorkspace } from '@/lib/worktree-activation' import { jumpToWorktreeFromSidebar } from '@/lib/worktree-jump-navigation' @@ -48,25 +47,6 @@ export function hasActivityThreadWorkspace( ) } -function toggleFloatingWorkspacePanelIfHidden(): void { - if (!isFloatingWorkspacePanelVisible()) { - window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) - } -} - -// Why enable first: floating tabs outlive a feature disable, and the panel ignores the toggle -// while disabled, so a live floating agent row would otherwise be a silent no-op. -function revealFloatingWorkspacePanel(state: AppState): void { - if (state.settings?.floatingTerminalEnabled === true) { - toggleFloatingWorkspacePanelIfHidden() - return - } - void state.updateSettings({ floatingTerminalEnabled: true }).then(() => { - // Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React. - requestAnimationFrame(toggleFloatingWorkspacePanelIfHidden) - }) -} - export function createActivityThreadActions({ getMarkAllReadThreads, acknowledgeAgents, diff --git a/src/renderer/src/components/codex-restart-chip.test.tsx b/src/renderer/src/components/codex-restart-chip.test.tsx index d3767b2dc92..bee04189ad4 100644 --- a/src/renderer/src/components/codex-restart-chip.test.tsx +++ b/src/renderer/src/components/codex-restart-chip.test.tsx @@ -4,7 +4,14 @@ import React, { act, Profiler } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '../store' -import CodexRestartChip from './CodexRestartChip' +import CodexRestartChipComponent from './CodexRestartChip' + +const returnFocus = vi.fn() +function CodexRestartChip( + props: Omit, 'onReturnFocus'> +) { + return +} globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -30,6 +37,7 @@ function button(scope: ParentNode, label: string): HTMLButtonElement { } beforeEach(() => { + returnFocus.mockReset() useAppStore.setState(useAppStore.getInitialState(), true) container = document.createElement('div') document.body.appendChild(container) @@ -118,6 +126,7 @@ describe('CodexRestartChip pane ownership', () => { button(firstPane, 'Restart').click() }) + expect(returnFocus).toHaveBeenCalledOnce() const state = useAppStore.getState() expect(state.pendingCodexPaneRestartIds).toEqual({ [PTY_ONE]: true }) expect(state.codexRestartNoticeByPtyId[PTY_ONE]?.restartRequested).toBe(true) @@ -155,6 +164,7 @@ describe('CodexRestartChip pane ownership', () => { button(firstPane, 'Keep old account').click() }) + expect(returnFocus).toHaveBeenCalledOnce() expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] }) expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_ONE]?.dismissed).toBe(true) expect(useAppStore.getState().codexRestartNoticeByPtyId[PTY_TWO]?.dismissed).toBeUndefined() @@ -273,6 +283,67 @@ describe('CodexRestartChip pane focus', () => { return terminalInput } + it.each(['outside', 'keep', 'escape', 'restart'])( + 'returns focus to the terminal after %s', + async (action) => { + const terminalInput = await renderPane(true) + returnFocus.mockImplementation(() => terminalInput.focus()) + const dialog = container.querySelector('[role="dialog"]')! + await act(async () => { + if (action === 'outside') { + dialog.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 0 })) + } else if (action === 'escape') { + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + } else { + button(container, action === 'restart' ? 'Restart' : 'Keep old account').click() + } + }) + expect(document.activeElement).toBe(terminalInput) + expect(container.querySelector('[role="dialog"]')).toBeNull() + expect(returnFocus).toHaveBeenCalledOnce() + if (action !== 'restart') { + expect(forgetStalePanes).toHaveBeenCalledExactlyOnceWith({ ptyIds: [PTY_ONE] }) + expect(useAppStore.getState().pendingCodexPaneRestartIds).toEqual({}) + } + } + ) + + it('does not dismiss when the card body is clicked', async () => { + await renderPane(true) + await act(async () => { + container + .querySelector('[role="dialog"] > div')! + .dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + expect(returnFocus).not.toHaveBeenCalled() + expect(forgetStalePanes).not.toHaveBeenCalled() + }) + + it('does not dismiss when a card text selection ends on the backdrop', async () => { + await renderPane(true) + const dialog = container.querySelector('[role="dialog"]')! + await act(async () => { + dialog.querySelector('div')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })) + dialog.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })) + dialog.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + expect(returnFocus).not.toHaveBeenCalled() + expect(forgetStalePanes).not.toHaveBeenCalled() + }) + + it('does not dismiss on a secondary mouse button press', async () => { + await renderPane(true) + await act(async () => { + container + .querySelector('[role="dialog"]')! + .dispatchEvent(new MouseEvent('mousedown', { bubbles: true, button: 2 })) + }) + expect(container.querySelector('[role="dialog"]')).not.toBeNull() + expect(returnFocus).not.toHaveBeenCalled() + }) + it('focuses the dialog itself for the active stale pane', async () => { const terminalInput = await renderPane(true) diff --git a/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx b/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx new file mode 100644 index 00000000000..60238ca3832 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/CodexOldTerminalDialog.tsx @@ -0,0 +1,54 @@ +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { translate } from '@/i18n/i18n' + +/** Learn more for a terminal opened before Orca gave each Codex its own server. */ +export function CodexOldTerminalDialog({ + open, + onOpenChange, + onOpenNewTerminal +}: { + open: boolean + onOpenChange: (open: boolean) => void + onOpenNewTerminal: () => void +}): React.JSX.Element { + return ( + + + + + {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogTitle', + 'Why this Codex shares a server' + )} + + + {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogRisk', + 'Codex sessions that share a server can end together, and agent status can be wrong.' + )} + + +

+ {translate( + 'terminal.codexSharedServerBanner.oldTerminalDialogCause', + 'Older terminals still share a Codex server. Open a new terminal to run Codex on a separate server.' + )} +

+ {/* Why no global fix: a new terminal already runs Codex on its own server here. */} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx index 434c05b0e85..94264705465 100644 --- a/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx +++ b/src/renderer/src/components/terminal-pane/CodexSharedServerBanner.tsx @@ -4,6 +4,14 @@ import { Button } from '@/components/ui/button' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { isCodexSharedServerWarningEnabled } from '../../../../shared/codex-terminal-server-isolation' +import type { CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { createFloatingWorkspaceTerminalTab } from '@/lib/floating-workspace-tab-creation' +import { revealFloatingWorkspacePanel } from '@/lib/floating-workspace-panel-reveal' +import { activateAndRevealWorkspace } from '@/lib/worktree-activation' +import { useModalReturnFocus } from '@/hooks/useModalReturnFocus' +import { CodexOldTerminalDialog } from './CodexOldTerminalDialog' import { CodexSharedServerFixDialog } from './CodexSharedServerFixDialog' import { retireCodexTerminalServerIsolationNotice } from './codex-terminal-server-isolation-notice' @@ -14,22 +22,22 @@ const dismissedPtyIds = new Set() /** Asks on the ladder until an answer is yes; returns a cancel that drops any later answer. */ function askUntilOnSharedServer( - ask: (ptyId: string) => Promise, + ask: (ptyId: string) => Promise, ptyId: string, - onJoined: () => void + onJoined: (status: CodexSharedServerStatus) => void ): () => void { let cancelled = false let timer: ReturnType | undefined const schedule = (attempt: number): void => { timer = setTimeout(() => { void ask(ptyId) - .catch(() => false) - .then((joined) => { + .catch((): CodexSharedServerStatus => ({ joined: false })) + .then((status) => { if (cancelled) { return } - if (joined) { - onJoined() + if (status.joined) { + onJoined(status) } else if (attempt + 1 < CHECK_DELAYS_MS.length) { schedule(attempt + 1) } @@ -43,21 +51,47 @@ function askUntilOnSharedServer( } } -function usePaneCodexOnSharedServer(ptyId: string, enabled: boolean, recheck: number): boolean { - const [joined, setJoined] = useState(false) +function usePaneCodexSharedServerStatus( + ptyId: string, + enabled: boolean, + recheck: number +): CodexSharedServerStatus | null { + const [status, setStatus] = useState(null) useEffect(() => { if (!enabled) { return } - const cancel = askUntilOnSharedServer(window.api.pty.isCodexOnSharedServer, ptyId, () => - setJoined(true) - ) + const cancel = askUntilOnSharedServer(window.api.pty.isCodexOnSharedServer, ptyId, setStatus) return () => { cancel() - setJoined(false) + setStatus(null) } }, [enabled, ptyId, recheck]) - return joined + return status +} + +/** Opens a new terminal where this tab lives; a new terminal's shell has Orca's codex wrapper. */ +function openTerminalBesideTab(terminalTabId: string): boolean { + const state = useAppStore.getState() + const tab = Object.values(state.unifiedTabsByWorktree) + .flat() + .find( + (candidate) => candidate.contentType === 'terminal' && candidate.entityId === terminalTabId + ) + if (!tab) { + return false + } + if (tab.worktreeId === FLOATING_TERMINAL_WORKTREE_ID) { + revealFloatingWorkspacePanel(state) + void createFloatingWorkspaceTerminalTab(state) + return true + } + // Why always: Activity can show this pane behind its own view even when its workspace is active. + if (activateAndRevealWorkspace(tab.worktreeId) === false) { + return false + } + void useAppStore.getState().openNewTerminalTabInActiveWorkspace(tab.groupId) + return true } /** Reserves the banner's height at the top of its pane so the terminal refits below it. */ @@ -85,11 +119,14 @@ function useReservePaneTopSpace(): React.RefObject { export function CodexSharedServerBanner({ ptyId, - paneKey + tabId, + leafId }: { ptyId: string - paneKey: string + tabId: string + leafId: string }): React.JSX.Element | null { + const paneKey = makePaneKey(tabId, leafId) const [dismissed, setDismissed] = useState(() => dismissedPtyIds.has(ptyId)) const warningEnabled = useAppStore( (state) => state.settings !== null && isCodexSharedServerWarningEnabled(state.settings) @@ -102,45 +139,123 @@ export function CodexSharedServerBanner({ ) // Why a recheck: after the fix stops the server, the banner hides unless a new one is joined. const [recheck, setRecheck] = useState(0) - const joined = usePaneCodexOnSharedServer( + const status = usePaneCodexSharedServerStatus( ptyId, warningEnabled && codexInPane && !dismissed, recheck ) - const [fixOpen, setFixOpen] = useState(false) - // Why fixOpen keeps it: stopping the server ends this pane's Codex, which must not close the dialog. - if (!joined && !fixOpen) { + const [openDialog, setOpenDialog] = useState<'fix' | 'oldTerminal' | null>(null) + // Why: neither dialog has a Radix trigger, so closing it would leave focus on document.body. + const { captureReturnFocus, skipReturnFocus } = useModalReturnFocus(openDialog !== null) + const showDialog = (dialog: 'fix' | 'oldTerminal'): void => { + captureReturnFocus() + setOpenDialog(dialog) + } + // Why an open dialog keeps it: this pane's Codex can end mid-dialog (Stop server does it), and + // the dialog must still close normally so focus returns. + if (!status && openDialog === null) { return null } + const { body, primaryAction } = + status?.joined && status.openedBeforeWrapper + ? { + body: ( + <> + {translate( + 'terminal.codexSharedServerBanner.openedBeforeUpdateBody', + 'This terminal was opened before Orca started giving each Codex its own server.' + )}{' '} + showDialog('oldTerminal')} /> + + ), + primaryAction: ( + + ) + } + : { + body: ( + <> + {translate( + 'terminal.codexSharedServerBanner.body', + 'Sessions may end unexpectedly, and agent status may be wrong.' + )}{' '} + showDialog('fix')} /> + + ), + primaryAction: ( + + ) + } + const closeDialog = (open: boolean): void => { + if (!open) { + setOpenDialog(null) + } + } return ( - setRecheck((count) => count + 1)} - onDismiss={() => { - dismissedPtyIds.add(ptyId) - setDismissed(true) - }} - onDontShowAgain={() => - void useAppStore.getState().updateSettings({ codexSharedServerWarning: false }) - } - /> + <> + {status ? ( + { + dismissedPtyIds.add(ptyId) + setDismissed(true) + }} + onDontShowAgain={() => + void useAppStore.getState().updateSettings({ codexSharedServerWarning: false }) + } + /> + ) : null} + { + // Why: a new terminal takes focus; only when none opens does focus return to this pane. + if (openTerminalBesideTab(tabId)) { + skipReturnFocus() + } + setOpenDialog(null) + }} + /> + setRecheck((count) => count + 1)} + /> + ) } -function CodexSharedServerBannerContent({ - ptyId, - fixOpen, - onFixOpenChange, - onServerStopped, +function LearnMoreLink({ onClick }: { onClick: () => void }): React.JSX.Element { + return ( + + ) +} + +function CodexSharedServerBannerFrame({ + body, + primaryAction, onDismiss, onDontShowAgain }: { - ptyId: string - fixOpen: boolean - onFixOpenChange: (open: boolean) => void - onServerStopped: () => void + body: React.ReactNode + primaryAction: React.ReactNode onDismiss: () => void onDontShowAgain: () => void }): React.JSX.Element { @@ -169,25 +284,11 @@ function CodexSharedServerBannerContent({ 'This Codex is sharing a server with your other Codex tabs' )}

-

- {translate( - 'terminal.codexSharedServerBanner.body', - 'Sessions may end unexpectedly, and agent status may be wrong.' - )}{' '} - -

+

{body}

- + {primaryAction} @@ -196,12 +297,6 @@ function CodexSharedServerBannerContent({
- ) } diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx index ff587ab8b95..ee003a527b1 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneRuntimePortals.tsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom' +import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import CodexRestartChip from '../CodexRestartChip' import { CodexSharedServerBanner } from './CodexSharedServerBanner' -import { makePaneKey } from '../../../../shared/stable-pane-id' import { TerminalSshReconnectOverlay } from './TerminalSshReconnectOverlay' import { TerminalRemoteRuntimeReconnectBanner } from './TerminalRemoteRuntimeReconnectBanner' import { TerminalProcessExitOverlay } from './TerminalProcessExitOverlay' @@ -17,8 +17,16 @@ export function TerminalPaneCodexRestartPortals({ }: { controller: TerminalPaneController }): React.JSX.Element { - const { activePane, isActive, isVisible, managedPanes, paneTransportsRef, savedLayout, tabId } = - controller + const { + activePane, + isActive, + isVisible, + managedPanes, + managerRef, + paneTransportsRef, + savedLayout, + tabId + } = controller return ( <> {managedPanes.map((pane) => { @@ -35,12 +43,17 @@ export function TerminalPaneCodexRestartPortals({ key={`codex-restart-${pane.id}-${ptyId}`} isVisible={isVisible} ptyId={ptyId} + onReturnFocus={() => { + managerRef.current?.setActivePane(pane.id, { focus: false }) + focusTerminalTabSurface(tabId, pane.leafId) + }} shouldFocus={isActive && isVisible && activePane?.id === pane.id} /> , pane.container, diff --git a/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx b/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx index 2f81ae371e0..1855062a1df 100644 --- a/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx +++ b/src/renderer/src/components/terminal-pane/codex-shared-server-banner.test.tsx @@ -6,20 +6,47 @@ import { toast } from 'sonner' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type { GlobalSettings } from '../../../../shared/global-settings-types' -import { getDefaultSettings } from '../../../../shared/constants' +import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../../shared/constants' import { CodexSharedServerBanner } from './CodexSharedServerBanner' import { CODEX_DISABLE_AUTO_START_COMMAND, - CODEX_STOP_SHARED_SERVER_COMMAND + CODEX_STOP_SHARED_SERVER_COMMAND, + type CodexSharedServerStatus } from '../../../../shared/codex-shared-server-command' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import type { Tab } from '../../../../shared/tab-types' + +const routing = vi.hoisted(() => ({ + activateAndRevealWorkspace: vi.fn<(id: string) => unknown>(), + revealFloatingWorkspacePanel: vi.fn(), + createFloatingWorkspaceTerminalTab: vi.fn() +})) +vi.mock('@/lib/worktree-activation', () => ({ + activateAndRevealWorkspace: routing.activateAndRevealWorkspace +})) +vi.mock('@/lib/floating-workspace-panel-reveal', () => ({ + revealFloatingWorkspacePanel: routing.revealFloatingWorkspacePanel +})) +vi.mock('@/lib/floating-workspace-tab-creation', () => ({ + createFloatingWorkspaceTerminalTab: routing.createFloatingWorkspaceTerminalTab +})) globalThis.IS_REACT_ACT_ENVIRONMENT = true -const PANE_KEY = 'tab-1:leaf-1' +const TAB_ID = 'tab-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID) +const JOINED: CodexSharedServerStatus = { joined: true, openedBeforeWrapper: false } +const NOT_JOINED: CodexSharedServerStatus = { joined: false } +const OLD_TAB_JOINED: CodexSharedServerStatus = { joined: true, openedBeforeWrapper: true } const TITLE = 'This Codex is sharing a server' +const OLD_TAB_BODY = + 'This terminal was opened before Orca started giving each Codex its own server.' let paneElement: HTMLDivElement let root: Root -let isCodexOnSharedServer: ReturnType Promise>> +let isCodexOnSharedServer: ReturnType< + typeof vi.fn<(id: string) => Promise> +> let disableCodexSharedServerAutoStart: ReturnType Promise>> let stopCodexSharedServer: ReturnType Promise>> let writeClipboardText: ReturnType Promise>> @@ -42,7 +69,7 @@ function setState(settings: Partial, agent: 'codex' | null = 'co async function renderBanner(): Promise { await act(async () => { - root.render() + root.render() }) } @@ -66,6 +93,8 @@ function button(label: string): HTMLButtonElement { beforeEach(() => { vi.useFakeTimers() + vi.clearAllMocks() + routing.activateAndRevealWorkspace.mockReturnValue({ primaryTabId: null }) vi.stubGlobal('ResizeObserver', ResizeObserverStub) useAppStore.setState(useAppStore.getInitialState(), true) ptyId = `pty-${(nextPtyId += 1)}` @@ -73,7 +102,7 @@ beforeEach(() => { paneElement.className = 'pane' document.body.appendChild(paneElement) root = createRoot(paneElement) - isCodexOnSharedServer = vi.fn(() => Promise.resolve(true)) + isCodexOnSharedServer = vi.fn(() => Promise.resolve(JOINED)) disableCodexSharedServerAutoStart = vi.fn(() => Promise.resolve(true)) stopCodexSharedServer = vi.fn(() => Promise.resolve(true)) writeClipboardText = vi.fn(() => Promise.resolve()) @@ -113,6 +142,19 @@ describe('CodexSharedServerBanner', () => { expect(paneElement.style.getPropertyValue('--orca-pane-top-banner-height')).toMatch(/px$/) }) + it('keeps the default Learn more on the Fix steps, with no old-terminal copy', async () => { + setState({}) + await renderBanner() + await advance(1_000) + expect(paneElement.textContent).not.toContain(OLD_TAB_BODY) + expect(() => button('Open new terminal')).toThrow() + button('Fix') + + await act(async () => button('Learn more').click()) + expect(document.body.textContent).toContain('Give each Codex tab its own server') + expect(document.body.textContent).not.toContain('Turn off sharing everywhere') + }) + it("retires the one-time 'runs Codex without its shared server' toast, which it contradicts", async () => { setState({}) useAppStore.setState({ codexTerminalServerIsolationNoticeSeen: false }) @@ -127,7 +169,7 @@ describe('CodexSharedServerBanner', () => { it('keeps asking while Codex starts, then stops once it has an answer', async () => { setState({}) - isCodexOnSharedServer.mockResolvedValueOnce(false) + isCodexOnSharedServer.mockResolvedValueOnce(NOT_JOINED) await renderBanner() await advance(1_000) expect(paneElement.textContent).toBe('') @@ -183,7 +225,7 @@ describe('CodexSharedServerBanner', () => { const confirm = Array.from(document.querySelectorAll('button')).filter( (candidate) => candidate.textContent?.trim() === 'Stop server' ) - isCodexOnSharedServer.mockResolvedValue(false) + isCodexOnSharedServer.mockResolvedValue(NOT_JOINED) await act(async () => confirm.at(-1)?.click()) expect(stopCodexSharedServer).toHaveBeenCalledWith(ptyId) expect(document.body.textContent).toContain('Stopped') @@ -252,4 +294,279 @@ describe('CodexSharedServerBanner', () => { await act(async () => setState({}, null)) expect(paneElement.textContent).toBe('') }) + + describe('in a terminal opened before the codex wrapper', () => { + function terminalTab(worktreeId: string, groupId: string): Tab { + return { + id: 'unified-1', + entityId: TAB_ID, + groupId, + worktreeId, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + } + + it('says why this terminal shares the server and offers a new one instead of Fix', async () => { + setState({}) + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + + expect(paneElement.textContent).toContain(TITLE) + expect(paneElement.textContent).toContain(OLD_TAB_BODY) + expect(paneElement.textContent).not.toContain('agent status may be wrong') + expect(() => button('Fix')).toThrow() + button('Open new terminal') + button("Don't show again") + button('Dismiss') + }) + + async function renderOldTab(worktreeId: string): Promise<() => Promise> { + const openNewTerminalTabInActiveWorkspace = vi.fn(() => Promise.resolve()) + setState({}) + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeView: 'activity', + unifiedTabsByWorktree: { [worktreeId]: [terminalTab(worktreeId, 'group-2')] }, + openNewTerminalTabInActiveWorkspace + }) + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + return openNewTerminalTabInActiveWorkspace + } + + function dialog(): Element | null { + return document.querySelector('[role="dialog"]') + } + + it('explains the old terminal in Learn more, with no global fix', async () => { + await renderOldTab('wt-1') + expect(dialog()).toBeNull() + await act(async () => button('Learn more').click()) + + expect(dialog()?.textContent).toContain('Why this Codex shares a server') + expect(dialog()?.textContent).toContain( + 'Open a new terminal to run Codex on a separate server.' + ) + expect(dialog()?.textContent).not.toContain('Turn off sharing everywhere') + expect(document.body.textContent).not.toContain(CODEX_DISABLE_AUTO_START_COMMAND) + expect(document.body.textContent).not.toContain(CODEX_STOP_SHARED_SERVER_COMMAND) + }) + + it('opens a new terminal from Learn more and closes the dialog', async () => { + const openNewTerminal = await renderOldTab('wt-1') + await act(async () => button('Learn more').click()) + const inDialog = Array.from(dialog()?.querySelectorAll('button') ?? []).find( + (candidate) => candidate.textContent?.trim() === 'Open new terminal' + ) + await act(async () => inDialog?.click()) + + expect(openNewTerminal).toHaveBeenCalledWith('group-2') + expect(dialog()).toBeNull() + expect(paneElement.textContent).toContain(OLD_TAB_BODY) + }) + + async function clickOpenNewTerminal(worktreeId: string): Promise<() => Promise> { + const openNewTerminalTabInActiveWorkspace = await renderOldTab(worktreeId) + await act(async () => button('Open new terminal').click()) + expect(disableCodexSharedServerAutoStart).not.toHaveBeenCalled() + expect(stopCodexSharedServer).not.toHaveBeenCalled() + return openNewTerminalTabInActiveWorkspace + } + + it.each([ + ['an active worktree viewed from Activity', 'wt-1'], + ['another worktree', 'wt-other'], + ['a folder workspace', 'folder:notes'] + ])('activates %s, then opens a terminal in its group', async (_label, worktreeId) => { + const openNewTerminal = await clickOpenNewTerminal(worktreeId) + expect(routing.activateAndRevealWorkspace).toHaveBeenCalledWith(worktreeId) + expect(openNewTerminal).toHaveBeenCalledWith('group-2') + }) + + it('opens nothing when its workspace cannot be activated', async () => { + routing.activateAndRevealWorkspace.mockReturnValue(false) + const openNewTerminal = await clickOpenNewTerminal('folder:unmounted') + expect(openNewTerminal).not.toHaveBeenCalled() + }) + + it('reveals the floating panel and opens a floating terminal', async () => { + const openNewTerminal = await clickOpenNewTerminal(FLOATING_TERMINAL_WORKTREE_ID) + expect(routing.revealFloatingWorkspacePanel).toHaveBeenCalledTimes(1) + expect(routing.createFloatingWorkspaceTerminalTab).toHaveBeenCalledTimes(1) + expect(routing.activateAndRevealWorkspace).not.toHaveBeenCalled() + expect(openNewTerminal).not.toHaveBeenCalled() + }) + }) + + describe('returning focus when a dialog closes', () => { + // Why a real xterm textarea: the dialogs have no trigger, so only the hook can restore focus. + function mountActiveTerminal(): HTMLTextAreaElement { + useAppStore.setState({ + activeWorktreeId: 'wt-1', + activeTabType: 'terminal', + activeTabIdByWorktree: { 'wt-1': TAB_ID }, + terminalLayoutsByTabId: { + [TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + } + }) + const tab = document.createElement('div') + tab.dataset.terminalTabId = TAB_ID + const leaf = document.createElement('div') + leaf.dataset.leafId = LEAF_ID + const xterm = document.createElement('textarea') + xterm.className = 'xterm-helper-textarea' + leaf.append(xterm) + tab.append(leaf) + document.body.append(tab) + xterm.focus() + return xterm + } + + async function pressEscape(): Promise { + await act(async () => { + ;(document.activeElement ?? document.body).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }) + ) + }) + await advance(100) + } + + afterEach(() => { + document.querySelector('[data-terminal-tab-id]')?.remove() + }) + + it.each([ + ['Fix', JOINED, 'Fix'], + ['old-terminal Learn more', OLD_TAB_JOINED, 'Learn more'] + ])( + 'returns focus to the terminal after Esc closes the %s dialog', + async (_l, status, label) => { + setState({}) + isCodexOnSharedServer.mockResolvedValue(status) + const xterm = mountActiveTerminal() + await renderBanner() + await advance(1_000) + await act(async () => button(label).click()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(document.activeElement).not.toBe(xterm) + + await pressEscape() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(xterm) + } + ) + + it('leaves focus in the new terminal after Open new terminal', async () => { + setState({}) + const xterm = mountActiveTerminal() + const newTerminal = document.createElement('textarea') + document.body.append(newTerminal) + useAppStore.setState({ + unifiedTabsByWorktree: { + 'wt-1': [ + { + id: 'unified-1', + entityId: TAB_ID, + groupId: 'group-1', + worktreeId: 'wt-1', + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + ] + }, + openNewTerminalTabInActiveWorkspace: vi.fn(() => { + newTerminal.focus() + return Promise.resolve() + }) + }) + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + await act(async () => button('Learn more').click()) + await clickDialogOpenNewTerminal() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(newTerminal) + expect(document.activeElement).not.toBe(xterm) + newTerminal.remove() + }) + + async function clickDialogOpenNewTerminal(): Promise { + const inDialog = Array.from( + document.querySelector('[role="dialog"]')?.querySelectorAll('button') ?? [] + ).find((candidate) => candidate.textContent?.trim() === 'Open new terminal') + await act(async () => inDialog?.click()) + await advance(100) + } + + it('returns focus to this terminal when Open new terminal cannot open one', async () => { + setState({}) + useAppStore.setState({ unifiedTabsByWorktree: {} }) + const xterm = mountActiveTerminal() + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + await act(async () => button('Learn more').click()) + + await clickDialogOpenNewTerminal() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(xterm) + }) + + it('keeps the Learn more dialog open when this Codex ends, then returns focus', async () => { + setState({}) + const xterm = mountActiveTerminal() + isCodexOnSharedServer.mockResolvedValue(OLD_TAB_JOINED) + await renderBanner() + await advance(1_000) + await act(async () => button('Learn more').click()) + + setState({}, null) + await advance(100) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(paneElement.textContent).toBe('') + + await pressEscape() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(document.activeElement).toBe(xterm) + }) + + it('returns focus to the pane terminal after Stop server ends its Codex', async () => { + setState({}) + const xterm = mountActiveTerminal() + await renderBanner() + await advance(1_000) + await act(async () => button('Fix').click()) + await act(async () => button('Turn off').click()) + await act(async () => button('Stop server').click()) + isCodexOnSharedServer.mockResolvedValue(NOT_JOINED) + const confirm = Array.from(document.querySelectorAll('button')).filter( + (candidate) => candidate.textContent?.trim() === 'Stop server' + ) + await act(async () => confirm.at(-1)?.click()) + await advance(20_000) + expect(stopCodexSharedServer).toHaveBeenCalledWith(ptyId) + expect(document.body.textContent).toContain('Stopped') + + await pressEscape() + + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(paneElement.textContent).toBe('') + expect(document.activeElement).toBe(xterm) + }) + }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 6378caec779..00ace381c7e 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -18761,7 +18761,12 @@ "codexSharedServerBanner": { "title": "This Codex is sharing a server with your other Codex tabs", "body": "Sessions may end unexpectedly, and agent status may be wrong.", + "openedBeforeUpdateBody": "This terminal was opened before Orca started giving each Codex its own server.", + "oldTerminalDialogTitle": "Why this Codex shares a server", + "oldTerminalDialogRisk": "Codex sessions that share a server can end together, and agent status can be wrong.", + "oldTerminalDialogCause": "Older terminals still share a Codex server. Open a new terminal to run Codex on a separate server.", "fix": "Fix", + "openNewTerminal": "Open new terminal", "dontShowAgain": "Don't show again", "dismiss": "Dismiss", "dialogTitle": "Give each Codex tab its own server", diff --git a/src/renderer/src/lib/floating-workspace-panel-reveal.ts b/src/renderer/src/lib/floating-workspace-panel-reveal.ts new file mode 100644 index 00000000000..af3a6e82314 --- /dev/null +++ b/src/renderer/src/lib/floating-workspace-panel-reveal.ts @@ -0,0 +1,24 @@ +import type { AppState } from '@/store/types' +import { TOGGLE_FLOATING_TERMINAL_EVENT } from './floating-terminal' +import { isFloatingWorkspacePanelVisible } from './floating-workspace-terminal-actions' + +function toggleFloatingWorkspacePanelIfHidden(): void { + if (!isFloatingWorkspacePanelVisible()) { + window.dispatchEvent(new Event(TOGGLE_FLOATING_TERMINAL_EVENT)) + } +} + +// Why enable first: floating tabs outlive a feature disable, and the panel ignores the toggle +// while disabled, so a live floating agent row would otherwise be a silent no-op. +export function revealFloatingWorkspacePanel( + state: Pick +): void { + if (state.settings?.floatingTerminalEnabled === true) { + toggleFloatingWorkspacePanelIfHidden() + return + } + void state.updateSettings({ floatingTerminalEnabled: true }).then(() => { + // Why deferred a frame: the panel only honors the toggle once the enabled flag has reached React. + requestAnimationFrame(toggleFloatingWorkspacePanelIfHidden) + }) +} diff --git a/src/renderer/src/web/preload-api/web-terminal-api.ts b/src/renderer/src/web/preload-api/web-terminal-api.ts index c4bf707ea3c..55c0ec364eb 100644 --- a/src/renderer/src/web/preload-api/web-terminal-api.ts +++ b/src/renderer/src/web/preload-api/web-terminal-api.ts @@ -41,7 +41,7 @@ export function createPtyApi(): NonNullable['pty']> { // Why: paired web panes cannot provide a local post-boundary process scan. confirmForegroundProcess: () => Promise.resolve(null), // Why: a paired client's terminals belong to the host, whose Codex settings this client does not own. - isCodexOnSharedServer: () => Promise.resolve(false), + isCodexOnSharedServer: () => Promise.resolve({ joined: false }), disableCodexSharedServerAutoStart: () => Promise.resolve(false), stopCodexSharedServer: () => Promise.resolve(false), getCwd: () => Promise.resolve('~'), diff --git a/src/shared/codex-shared-server-command.ts b/src/shared/codex-shared-server-command.ts index 74c242b4e3b..51343d3d75a 100644 --- a/src/shared/codex-shared-server-command.ts +++ b/src/shared/codex-shared-server-command.ts @@ -120,3 +120,12 @@ export const CODEX_STOP_SHARED_SERVER_ARGS = ['app-server', 'daemon', 'stop'] as /** What the fix dialog shows the user it runs. */ export const CODEX_DISABLE_AUTO_START_COMMAND = `codex ${CODEX_DISABLE_SHARED_SERVER_ARGS.join(' ')}` export const CODEX_STOP_SHARED_SERVER_COMMAND = `codex ${CODEX_STOP_SHARED_SERVER_ARGS.join(' ')}` + +/** Whether a local pane's Codex is a client of Codex's shared server. */ +export type CodexSharedServerStatus = + | { joined: false } + | { + joined: true + /** The pane's shell lacks the codex function a new terminal would give it. */ + openedBeforeWrapper: boolean + } diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index fd2e76664f8..fd9a58fb5fa 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -407,6 +407,12 @@ export type GlobalSettings = { agentYoloDefaultsMigrated?: boolean /** Why: disabling must persist so startup doesn't reinstall global agent hook entries the user just removed. */ agentStatusHooksEnabled: boolean + /** A local agent-state-rules.json that replaces downloaded and bundled rules, for testing a rule + * change. */ + agentStateRulesPath?: string | null + /** Off: rules are never downloaded and a cached download is ignored, so the bundled rules (or a + * local override) apply. Absent reads as on. */ + agentStateRulesLiveUpdates?: boolean /** Pre-trust the worktree or folder Orca starts an agent in, so its "trust this folder?" prompt is skipped. Defaults on. */ agentWorkspaceTrustEnabled: boolean /** Why: Codex's shared server runs every tab's hooks with the first tab's env; off opts new terminals back into it. Absent reads as on. */ diff --git a/src/shared/telemetry-app-event-schemas.ts b/src/shared/telemetry-app-event-schemas.ts index 888eb389046..6d6c3e88282 100644 --- a/src/shared/telemetry-app-event-schemas.ts +++ b/src/shared/telemetry-app-event-schemas.ts @@ -20,6 +20,7 @@ import { requestKindSchema, workspaceSourceSchema } from './telemetry-property-schemas' +import { workspaceCreatedTimingProperties } from './telemetry-workspace-create-schemas' // ── Per-event schemas ─────────────────────────────────────────────────── @@ -103,7 +104,8 @@ export const workspaceCreatedSchema = z .object({ source: workspaceSourceSchema, from_existing_branch: z.boolean(), - nth_repo_added: nthRepoAddedSchema + nth_repo_added: nthRepoAddedSchema, + ...workspaceCreatedTimingProperties }) .strict() diff --git a/src/shared/telemetry-repository-event-schemas.ts b/src/shared/telemetry-repository-event-schemas.ts index f7be0dcff31..f0adde31b30 100644 --- a/src/shared/telemetry-repository-event-schemas.ts +++ b/src/shared/telemetry-repository-event-schemas.ts @@ -10,6 +10,7 @@ import { bucketNestedRepoTelemetryCount } from './nested-repo-telemetry' import { nthRepoAddedSchema } from './telemetry-app-event-schemas' +import { workspaceCreateFailedProperties } from './telemetry-workspace-create-schemas' import { addRepoDefaultCheckoutHandoffReasonSchema, addRepoDefaultCheckoutHandoffResultSchema, @@ -62,7 +63,8 @@ export const workspaceCreateFailedSchema = z .object({ source: workspaceSourceSchema, error_class: workspaceCreateErrorClassSchema, - nth_repo_added: nthRepoAddedSchema + nth_repo_added: nthRepoAddedSchema, + ...workspaceCreateFailedProperties }) .strict() diff --git a/src/shared/telemetry-workspace-create-schemas.test.ts b/src/shared/telemetry-workspace-create-schemas.test.ts new file mode 100644 index 00000000000..d7ad8277139 --- /dev/null +++ b/src/shared/telemetry-workspace-create-schemas.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from './telemetry-event-registry' +import { WORKTREE_CREATE_PHASES } from './worktree/create-timing-vocabulary' + +const created = eventSchemas.workspace_created +const failed = eventSchemas.workspace_create_failed + +const fullCreatedPayload = { + source: 'command_palette', + from_existing_branch: false, + nth_repo_added: 3, + total_ms: 8_412, + unattributed_ms: 311, + refresh_base_ref_ms: 2_040, + git_worktree_add_ms: 4_900, + prepared_checkout_claim_ms: 12, + prepared_checkout_wait_ms: 3_100, + prepared_checkout_finalize_ms: 120, + list_created_worktree_ms: 640, + spawn_startup_terminal_ms: 90, + prepared_checkout: 'hit', + prepared_checkout_reset: 'retargeted', + prepared_checkout_origin: 'rearm_then_prefetch', + prepared_checkout_build_ms: 41_000, + prepared_checkout_idle_ms: 0, + create_entry_point: 'runtime', + execution_host: 'wsl', + worktree_count_bucket: '301-1000', + repo_file_count_bucket: '100k-500k', + concurrent_creates: 2, + concurrent_preparations: 1, + post_checkout_hook: 'present' +} + +describe('workspace_created timing fields', () => { + it('still accepts the payload that predates the timing fields', () => { + expect(created.safeParse({ source: 'sidebar', from_existing_branch: true }).success).toBe(true) + }) + + it('accepts a full payload', () => { + expect(created.safeParse(fullCreatedPayload).success).toBe(true) + }) + + it('accepts a duration for every timed phase', () => { + const phases = Object.fromEntries(WORKTREE_CREATE_PHASES.map((phase) => [`${phase}_ms`, 1])) + expect(created.safeParse({ ...fullCreatedPayload, ...phases }).success).toBe(true) + }) + + it('accepts a miss with its reason', () => { + const parsed = created.safeParse({ + source: 'sidebar', + from_existing_branch: false, + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'none_armed' + }) + expect(parsed.success).toBe(true) + }) + + it.each([ + ['repo_path', '/Users/alice/secret-repo'], + ['branch', 'alice/feature'], + ['base_ref', 'origin/main'], + ['hook_content', '#!/bin/sh\ngit lfs post-checkout'], + ['fetch_ms', 10] + ])('rejects the unknown key %s via .strict()', (key, value) => { + expect(created.safeParse({ ...fullCreatedPayload, [key]: value }).success).toBe(false) + }) + + it.each([ + ['total_ms', '8412'], + ['total_ms', -1], + ['git_worktree_add_ms', 1.5], + ['concurrent_creates', -1], + ['concurrent_preparations', 0.5], + ['prepared_checkout', 'maybe'], + ['prepared_checkout_reset', 'hard'], + ['prepared_checkout_origin', 'dialog'], + ['create_entry_point', 'cli'], + ['repo_file_count_bucket', '12000'], + ['prepared_checkout_idle_ms', -5], + ['prepared_checkout_miss_reason', 'the branch alice/feature was missing'], + ['execution_host', 'docker'], + ['worktree_count_bucket', '812'], + ['post_checkout_hook', 'lfs'] + ])('rejects %s = %j', (key, value) => { + expect(created.safeParse({ ...fullCreatedPayload, [key]: value }).success).toBe(false) + }) +}) + +describe('workspace_create_failed timing fields', () => { + it('still accepts the payload that predates the timing fields', () => { + expect(failed.safeParse({ source: 'sidebar', error_class: 'git_failed' }).success).toBe(true) + }) + + it('accepts the failing phase and elapsed time', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + failed_phase: 'git_worktree_add', + total_ms: 120_000, + create_entry_point: 'app', + execution_host: 'ssh', + concurrent_creates: 0, + concurrent_preparations: 0 + }) + expect(parsed.success).toBe(true) + }) + + it('accepts the prepared-checkout outcome and wait of the failed create', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + failed_phase: 'git_worktree_add', + prepared_checkout: 'miss', + prepared_checkout_miss_reason: 'finalize_failed', + prepared_checkout_wait_ms: 209_000 + }) + expect(parsed.success).toBe(true) + }) + + it('accepts a failure outside every timed phase', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'unknown', + failed_phase: 'untimed' + }) + expect(parsed.success).toBe(true) + }) + + it('rejects a phase outside the closed vocabulary', () => { + const parsed = failed.safeParse({ + source: 'sidebar', + error_class: 'git_failed', + failed_phase: 'fatal: could not create work tree' + }) + expect(parsed.success).toBe(false) + }) + + it.each([ + ['post_checkout_hook', 'present'], + ['repo_file_count_bucket', '<1k'], + ['git_worktree_add_ms', 10] + ])('rejects the success-only field %s via .strict()', (key, value) => { + const parsed = failed.safeParse({ source: 'sidebar', error_class: 'git_failed', [key]: value }) + expect(parsed.success).toBe(false) + }) +}) diff --git a/src/shared/telemetry-workspace-create-schemas.ts b/src/shared/telemetry-workspace-create-schemas.ts new file mode 100644 index 00000000000..f06f07b0bd1 --- /dev/null +++ b/src/shared/telemetry-workspace-create-schemas.ts @@ -0,0 +1,121 @@ +import { z } from 'zod' +import { + PREPARED_CHECKOUT_MISS_REASONS, + PREPARED_CHECKOUT_ORIGINS, + PREPARED_CHECKOUT_RESETS, + WORKSPACE_CREATE_ENTRY_POINTS, + WORKTREE_CREATE_EXECUTION_HOSTS, + WORKTREE_CREATE_PHASES, + type WorktreeCreatePhase +} from './worktree/create-timing-vocabulary' + +// Why numbers and closed enums only: these fields explain where create time went on real +// machines; a path, branch, ref or error string would identify the repo, so none may appear. +// Every field is optional: folder workspaces and creates outside the timed paths omit them. + +const durationMsSchema = z.number().int().nonnegative().optional() + +export const WORKTREE_COUNT_BUCKETS = [ + '1', + '2-5', + '6-20', + '21-100', + '101-300', + '301-1000', + '1001+' +] as const + +/** Files the main repo tracks (its index entry count); checkout cost grows with it, and these split + * small projects, typical apps, kernel-sized repos, large monorepos and the very largest. */ +export const REPO_FILE_COUNT_BUCKETS = ['<1k', '1k-10k', '10k-100k', '100k-500k', '500k+'] as const + +export const POST_CHECKOUT_HOOK_PRESENCE_VALUES = [ + 'present', + 'absent', + 'custom_hooks_path', + 'unknown' +] as const + +const phaseDurationProperties = { + resolve_name_ms: durationMsSchema, + refresh_base_ref_ms: durationMsSchema, + git_worktree_add_ms: durationMsSchema, + prepared_checkout_claim_ms: durationMsSchema, + prepared_checkout_wait_ms: durationMsSchema, + prepared_checkout_finalize_ms: durationMsSchema, + prepared_checkout_discard_ms: durationMsSchema, + list_created_worktree_ms: durationMsSchema, + persist_metadata_ms: durationMsSchema, + create_symlinks_ms: durationMsSchema, + resolve_shared_directories_ms: durationMsSchema, + resolve_worktreeinclude_ms: durationMsSchema, + create_shared_directories_ms: durationMsSchema, + copy_worktreeinclude_ms: durationMsSchema, + prepare_setup_ms: durationMsSchema, + spawn_startup_terminal_ms: durationMsSchema +} + +// Compile-time: one duration key per timed phase, no more and no fewer. +type _PhaseKeys = `${WorktreeCreatePhase}_ms` +type _PhaseDurationKeys = keyof typeof phaseDurationProperties +type _PhaseDurationSync = [_PhaseKeys] extends [_PhaseDurationKeys] + ? [_PhaseDurationKeys] extends [_PhaseKeys] + ? true + : never + : never +const _phaseDurationSyncCheck: _PhaseDurationSync = true +void _phaseDurationSyncCheck + +const countSchema = z.number().int().nonnegative().optional() + +/** Fields both events carry: which entry point ran, where Git ran, and what competed for the disk. */ +const workspaceCreateContextProperties = { + create_entry_point: z.enum(WORKSPACE_CREATE_ENTRY_POINTS).optional(), + execution_host: z.enum(WORKTREE_CREATE_EXECUTION_HOSTS).optional(), + /** Most other worktree creates running in this app at once during this one. */ + concurrent_creates: countSchema, + /** Most prepared-checkout builds and discards running at once during this create, not counting + * the prepared checkout this create used. */ + concurrent_preparations: countSchema +} + +/** The prepared-checkout outcome, sent on success and failure alike. */ +const preparedCheckoutProperties = { + prepared_checkout: z.enum(['hit', 'miss']).optional(), + /** Hit only: the reset the prepared checkout needed before it was handed over. */ + prepared_checkout_reset: z.enum(PREPARED_CHECKOUT_RESETS).optional(), + /** Hit only: the new-worktree UI's prefetch, the automatic burst replacement, or that + * replacement which the prefetch then asked for too. */ + prepared_checkout_origin: z.enum(PREPARED_CHECKOUT_ORIGINS).optional(), + /** Hit only: from the start of the prepared checkout's build to it being ready. */ + prepared_checkout_build_ms: durationMsSchema, + /** Hit only: how long it sat ready before this create claimed it; 0 when the create waited. */ + prepared_checkout_idle_ms: durationMsSchema, + prepared_checkout_miss_reason: z.enum(PREPARED_CHECKOUT_MISS_REASONS).optional() +} + +export const workspaceCreatedTimingProperties = { + total_ms: durationMsSchema, + /** Wall-clock time no timed phase covers. */ + unattributed_ms: durationMsSchema, + // The prepared_checkout_* phases run inside git_worktree_add, so the plain checkout is the + // add minus them. + ...phaseDurationProperties, + ...preparedCheckoutProperties, + ...workspaceCreateContextProperties, + worktree_count_bucket: z.enum(WORKTREE_COUNT_BUCKETS).optional(), + repo_file_count_bucket: z.enum(REPO_FILE_COUNT_BUCKETS).optional(), + post_checkout_hook: z.enum(POST_CHECKOUT_HOOK_PRESENCE_VALUES).optional() +} + +/** `untimed` = the create failed outside every timed phase. */ +export const WORKSPACE_CREATE_FAILED_PHASE_VALUES = [...WORKTREE_CREATE_PHASES, 'untimed'] as const + +export const workspaceCreateFailedProperties = { + failed_phase: z.enum(WORKSPACE_CREATE_FAILED_PHASE_VALUES).optional(), + /** Elapsed time from the start of the create to the failure. */ + total_ms: durationMsSchema, + ...preparedCheckoutProperties, + prepared_checkout_wait_ms: durationMsSchema, + ...workspaceCreateContextProperties +} diff --git a/src/shared/worktree/create-timing-vocabulary.ts b/src/shared/worktree/create-timing-vocabulary.ts new file mode 100644 index 00000000000..48283050eb6 --- /dev/null +++ b/src/shared/worktree/create-timing-vocabulary.ts @@ -0,0 +1,74 @@ +/** Every phase a worktree create times. Closed so phase names can become span attributes and + * event fields without ever carrying a branch name, a ref, or a path. */ +export const WORKTREE_CREATE_PHASES = [ + 'resolve_name', + 'refresh_base_ref', + 'git_worktree_add', + /** Picking and reserving a prepared checkout: base probe and drift check included. */ + 'prepared_checkout_claim', + 'prepared_checkout_wait', + 'prepared_checkout_finalize', + /** Removing a prepared checkout whose finalize failed, before the plain add runs. */ + 'prepared_checkout_discard', + 'list_created_worktree', + 'persist_metadata', + 'create_symlinks', + 'resolve_shared_directories', + 'resolve_worktreeinclude', + 'create_shared_directories', + 'copy_worktreeinclude', + 'prepare_setup', + 'spawn_startup_terminal' +] as const + +export type WorktreeCreatePhase = (typeof WORKTREE_CREATE_PHASES)[number] + +/** Closed vocabulary: these values reach span attributes, so none of them may ever + * be derived from a branch name, a ref, or a path. */ +export const PREPARED_CHECKOUT_MISS_REASONS = [ + 'none_armed', + /** Preparations exist, but none for this repo — it was never warmed, or the pool's size cap + * evicted it for another repo. Distinguished from `none_armed` because it is the signal that + * the cap is thrashing for a multi-project user. */ + 'repo_mismatch', + 'base_mismatch', + 'retarget_too_divergent', + /** The drift check returned no answer. Distinct from `retarget_too_divergent` because that one + * is the bound working as intended, while this one means a possibly cheap retarget was skipped + * anyway. Deliberately a mixed bucket — a blown deadline, a cancelled create, and an ordinary + * Git failure such as a missing ref all land here — so treat a rise as "look at why", not as a + * direct readout of the budget being too small. */ + 'retarget_unverifiable', + 'workspace_root_mismatch', + 'wsl_distro_mismatch', + 'prepare_failed', + 'finalize_failed', + 'checkout_existing_branch', + 'sparse_checkout' +] as const + +export type PreparedCheckoutMissReason = (typeof PREPARED_CHECKOUT_MISS_REASONS)[number] + +/** The reset a prepared-checkout hit needed: none, the same ref having moved since it was + * prepared, or a different ref in the same base family. */ +export const PREPARED_CHECKOUT_RESETS = ['none', 'base_moved', 'retargeted'] as const + +export type PreparedCheckoutReset = (typeof PREPARED_CHECKOUT_RESETS)[number] + +/** Who asked for the prepared checkout a hit used: the new-worktree UI's prefetch, the automatic + * replacement armed after an earlier create in a burst, or that replacement which the UI's + * prefetch then asked for too (the re-arm only gave it a head start). */ +export const PREPARED_CHECKOUT_ORIGINS = ['prefetch', 'rearm', 'rearm_then_prefetch'] as const + +export type PreparedCheckoutOrigin = (typeof PREPARED_CHECKOUT_ORIGINS)[number] + +/** Where the create's Git ran: the host itself, a WSL distro on it, or an SSH remote. */ +export const WORKTREE_CREATE_EXECUTION_HOSTS = ['local', 'wsl', 'ssh'] as const + +export type WorktreeCreateExecutionHost = (typeof WORKTREE_CREATE_EXECUTION_HOSTS)[number] + +/** Which create entry point ran: Orca's own window (`app`) or the runtime API that the CLI, + * agents, the phone app, paired clients and orchestration call (`runtime`). */ +export const WORKSPACE_CREATE_ENTRY_POINTS = ['app', 'runtime'] as const + +export type WorkspaceCreateEntryPoint = (typeof WORKSPACE_CREATE_ENTRY_POINTS)[number] diff --git a/src/shared/worktree/create-types.ts b/src/shared/worktree/create-types.ts index 5032df3924c..c15b73628b6 100644 --- a/src/shared/worktree/create-types.ts +++ b/src/shared/worktree/create-types.ts @@ -19,12 +19,25 @@ import type { WorktreeSetupLaunch, WorktreeStartupLaunch } from './launch-types' +import type { + PreparedCheckoutMissReason, + PreparedCheckoutOrigin, + PreparedCheckoutReset, + WorktreeCreateExecutionHost +} from './create-timing-vocabulary' import type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion, WorktreeBaseStatusEvent } from './base-ref-drift-types' +export type { + PreparedCheckoutMissReason, + PreparedCheckoutOrigin, + PreparedCheckoutReset, + WorktreeCreateExecutionHost +} + export type SetupDecision = 'inherit' | 'run' | 'skip' export type WorktreeCreateTimingPhase = { @@ -33,40 +46,26 @@ export type WorktreeCreateTimingPhase = { durationMs: number } -/** Closed vocabulary: these values reach span attributes, so none of them may ever - * be derived from a branch name, a ref, or a path. */ -export type PreparedCheckoutMissReason = - | 'none_armed' - /** Preparations exist, but none for this repo — it was never warmed, or the pool's size cap - * evicted it for another repo. Distinguished from `none_armed` because it is the signal that - * the cap is thrashing for a multi-project user. */ - | 'repo_mismatch' - | 'base_mismatch' - | 'retarget_too_divergent' - /** The drift check returned no answer. Distinct from `retarget_too_divergent` because that one - * is the bound working as intended, while this one means a possibly cheap retarget was skipped - * anyway. Deliberately a mixed bucket — a blown deadline, a cancelled create, and an ordinary - * Git failure such as a missing ref all land here — so treat a rise as "look at why", not as a - * direct readout of the budget being too small. */ - | 'retarget_unverifiable' - | 'workspace_root_mismatch' - | 'wsl_distro_mismatch' - | 'prepare_failed' - | 'finalize_failed' - | 'checkout_existing_branch' - | 'sparse_checkout' - /** Whether a create reused a prewarmed checkout, and when it did not, which part of - * the claim key disagreed. `retargeted` marks a hit that had to reset the prepared - * checkout onto a different ref in the same base family. */ + * the claim key disagreed. A hit says what reset it needed, who armed it, how long it took + * to build and how long it then sat ready before this create claimed it. */ export type PreparedCheckoutOutcome = - | { status: 'hit'; retargeted: boolean } + | { + status: 'hit' + reset: PreparedCheckoutReset + origin: PreparedCheckoutOrigin + buildMs: number + idleMs: number + } | { status: 'miss'; reason: PreparedCheckoutMissReason } export type WorktreeCreateTiming = { totalDurationMs: number phases: WorktreeCreateTimingPhase[] preparedCheckout?: PreparedCheckoutOutcome + executionHost?: WorktreeCreateExecutionHost + /** Worktrees Git listed after the create, main checkout included; absent when the listing failed. */ + worktreeCount?: number } export type CreateSparseCheckoutRequest = { diff --git a/tests/e2e/codex-account-switch-dismiss.spec.ts b/tests/e2e/codex-account-switch-dismiss.spec.ts new file mode 100644 index 00000000000..f85a2c22f5a --- /dev/null +++ b/tests/e2e/codex-account-switch-dismiss.spec.ts @@ -0,0 +1,76 @@ +import { expect, test } from './helpers/orca-app' +import { + configureGoldenStubAgent, + getGoldenStubAgentLaunchEnv, + launchGoldenStubAgentFromNewTab +} from './helpers/golden-stub-agent' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePanePtyId, waitForTerminalOutput } from './helpers/terminal' + +test.use({ launchEnv: getGoldenStubAgentLaunchEnv() }) + +test('account switch actions return keyboard input to the terminal', async ({ + orcaPage +}, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await configureGoldenStubAgent(orcaPage) + await launchGoldenStubAgentFromNewTab(orcaPage) + + for (const action of ['outside', 'keep', 'escape', 'restart'] as const) { + const ptyId = await waitForActivePanePtyId(orcaPage) + await orcaPage.evaluate( + ({ ptyId, action }) => { + window.__store.getState().markCodexRestartNotices([ + { + ptyId, + previousAccountLabel: 'Previous account', + nextAccountLabel: `Current account (${action})` + } + ]) + }, + { ptyId, action } + ) + const dialog = orcaPage.getByRole('dialog').filter({ hasText: 'Account switched' }) + await expect(dialog).toBeVisible() + await expect(dialog).toBeFocused() + if (action === 'outside') { + const cardText = await dialog.getByText(/Restart this session to use/).boundingBox() + const backdrop = await dialog.boundingBox() + if (!cardText || !backdrop) { + throw new Error('Account prompt has no rendered bounds') + } + await orcaPage.mouse.move(cardText.x + 8, cardText.y + 8) + await orcaPage.mouse.down() + await orcaPage.mouse.move(backdrop.x + 8, backdrop.y + 8) + await orcaPage.mouse.up() + await expect(dialog).toBeVisible() + await orcaPage.screenshot({ path: testInfo.outputPath('account-switched.png') }) + await dialog.click({ position: { x: 8, y: 8 } }) + } else if (action === 'escape') { + await orcaPage.keyboard.press('Escape') + } else { + await dialog + .getByRole('button', { + name: action === 'keep' ? 'Keep old account' : 'Restart', + exact: true + }) + .click() + } + await expect(dialog).toHaveCount(0) + const input = orcaPage.locator('.xterm-helper-textarea:focus') + await expect(input).toHaveCount(1) + if (action === 'restart') { + // This check covers the focus handoff; process replacement is a separate contract. + break + } + // A printed reply proves input reached the process without an extra terminal click. + const marker = `ACCOUNT_SWITCH_${action.toUpperCase()}` + await orcaPage.keyboard.type(marker) + await orcaPage.keyboard.press('Enter') + await waitForTerminalOutput(orcaPage, `[GOLDEN_STUB_AGENT_SUBMITTED] ${marker}`, 20_000) + await expect(input).toHaveCount(1) + await orcaPage.screenshot({ path: testInfo.outputPath(`after-${action}.png`) }) + } +}) diff --git a/tests/tools/repro-watcher-crash-7547/child.cjs b/tests/tools/repro-watcher-crash-7547/child.cjs index d5a522c0b40..57657efac04 100644 --- a/tests/tools/repro-watcher-crash-7547/child.cjs +++ b/tests/tools/repro-watcher-crash-7547/child.cjs @@ -32,7 +32,7 @@ const os = require('node:os') const path = require('node:path') const { Worker, isMainThread, workerData } = require('node:worker_threads') -const REPO_ROOT = path.resolve(__dirname, '..', '..') +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') // The exact bundled native module (@parcel/watcher 2.5.6 + watcher-win32-x64). const watcherPath = path.join(REPO_ROOT, 'node_modules', '@parcel', 'watcher') diff --git a/tests/tools/repro-watcher-crash-7547/fixed-child.cjs b/tests/tools/repro-watcher-crash-7547/fixed-child.cjs index 99a5d782cc3..47b9749e48e 100644 --- a/tests/tools/repro-watcher-crash-7547/fixed-child.cjs +++ b/tests/tools/repro-watcher-crash-7547/fixed-child.cjs @@ -17,23 +17,21 @@ const os = require('node:os') const path = require('node:path') const { Worker, isMainThread, workerData } = require('node:worker_threads') -const REPO_ROOT = path.resolve(__dirname, '..', '..') +const REPO_ROOT = path.resolve(__dirname, '..', '..', '..') // The real host client, bundled on demand so the harness always exercises the // current src/main/ipc/parcel-watcher-process.ts. const bundlePath = path.join(__dirname, 'parcel-watcher-process.bundle.cjs') if (isMainThread) { const clientSrc = path.join(REPO_ROOT, 'src', 'main', 'ipc', 'parcel-watcher-process.ts') - if ( - !fs.existsSync(bundlePath) || - fs.statSync(bundlePath).mtimeMs < fs.statSync(clientSrc).mtimeMs - ) { - require('node:child_process').execSync( - `npx esbuild ${JSON.stringify(clientSrc)} --bundle --platform=node --format=cjs ` + - `--external:electron --external:@parcel/watcher --outfile=${JSON.stringify(bundlePath)}`, - { cwd: REPO_ROOT, stdio: 'inherit' } - ) - } + require('esbuild').buildSync({ + entryPoints: [clientSrc], + bundle: true, + platform: 'node', + format: 'cjs', + external: ['electron', '@parcel/watcher'], + outfile: bundlePath + }) const entry = path.join(process.cwd(), 'out', 'main', 'parcel-watcher-process-entry.js') if (!fs.existsSync(entry)) { process.stderr.write( @@ -156,8 +154,10 @@ function subscribeLikeOrca(dir) { stats.events += events.length }, OPTS, - () => { - stats.interruptions++ + { + onInterruption: () => { + stats.interruptions++ + } } ) .then((sub) => {