diff --git a/.gitattributes b/.gitattributes index b1dacdd5456..565a0287996 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,3 +10,5 @@ /skill-stubs/*.md text eol=lf /skills/*/SKILL.md text eol=lf /src/cli/bundled-skill-guides.ts text eol=lf +# Bundled plugin trees are byte-hashed; CRLF checkout would break the pinned hash. +/resources/plugins/** text eol=lf diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml new file mode 100644 index 00000000000..0f3cba4107a --- /dev/null +++ b/.github/actions/install-node-dependencies/action.yml @@ -0,0 +1,68 @@ +name: Install Node dependencies +description: Installs the Node toolchain and repository dependencies for Linux CI jobs. + +inputs: + native-runtime: + description: Native runtime to prepare after the script-free install (none, node, or electron). + required: false + default: none + +runs: + using: composite + steps: + # setup-node needs pnpm on PATH to locate and restore its store. + - name: Setup pnpm + uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version-file: package.json + cache: pnpm + + - name: Validate native runtime + shell: bash + env: + NATIVE_RUNTIME: ${{ inputs.native-runtime }} + run: | + case "$NATIVE_RUNTIME" in + none|node|electron) ;; + *) + echo "::error::native-runtime must be none, node, or electron" + exit 2 + ;; + esac + + # pnpm's bundled gyp_main.py is not executable on fresh Linux runners. + - name: Use external node-gyp + if: inputs.native-runtime != 'none' + shell: bash + run: | + npm install -g node-gyp@11.5.0 + echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" + + - name: Prepare dependency install + shell: bash + run: | + if [ -e node_modules ]; then + ls -ld node_modules + rm -rf node_modules + fi + + - name: Install dependencies + shell: bash + run: | + pnpm install \ + --no-frozen-lockfile \ + --prefer-frozen-lockfile=false \ + --ignore-scripts + git diff --exit-code package.json pnpm-lock.yaml + + - name: Prepare native runtime + if: inputs.native-runtime != 'none' + shell: bash + env: + NATIVE_RUNTIME: ${{ inputs.native-runtime }} + run: node config/scripts/ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME" diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e444a498f0f..a20edab4f44 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -8,88 +8,56 @@ on: - reopened - ready_for_review +concurrency: + group: pr-checks-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + jobs: - verify: + static_analysis: + name: static analysis runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: - # Why: verify:skill-bundle-manifest now checks working-tree bytes - # against the committed skill ledger (advanced only at release cut) and - # no longer walks release tags, so a shallow checkout is sufficient. persist-credentials: false - - name: Install native build tools - run: sudo apt-get update && sudo apt-get install -y build-essential python3 zlib1g-dev zsh - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version-file: package.json - - - name: Setup pnpm - uses: pnpm/action-setup@v6 - with: - run_install: false - - # Why: pnpm's bundled node-gyp ships gyp_main.py without execute - # permission, which breaks native module builds (e.g. node-pty's - # postinstall) with "/bin/sh: gyp_main.py: Permission denied". - # Pin the fallback to the lockfile's node-gyp version so CI stays - # reproducible while forcing pnpm to bypass its broken bundled copy. - # Gate on runner.os == 'Linux' to match release.yml — the - # npm-global path layout this step assumes is POSIX-shaped, and the - # failure has only been observed on Linux runners. Today this job - # pins runs-on: ubuntu-latest so the guard is a no-op, but it - # prevents a silent break if a Windows/macOS matrix is added later. - - name: Use external node-gyp to avoid pnpm's bundled copy (Linux only) - if: runner.os == 'Linux' - run: | - npm install -g node-gyp@11.5.0 - echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV" - - - name: Prepare dependency install - run: | - if [ -e node_modules ]; then - ls -ld node_modules - rm -rf node_modules - fi - - - name: Install dependencies - # Why: pnpm 10.24's frozen headless fast path can fail on fresh Ubuntu - # runners while creating the root node_modules. Use the normal resolver - # path, then verify package metadata stayed unchanged. - run: | - pnpm install --no-frozen-lockfile --prefer-frozen-lockfile=false - git diff --exit-code package.json pnpm-lock.yaml + - uses: ./.github/actions/install-node-dependencies - name: Lint run: pnpm exec oxlint --format github + - name: Check switch exhaustiveness + run: pnpm run lint:switch-exhaustiveness + - name: Check styled scrollbars run: pnpm check:styled-scrollbars + - name: Check quadratic buffer concatenation + run: pnpm run check:quadratic-buffer-concat + - name: Check reliability gate manifest run: pnpm run check:reliability-gates - # Why: oxlint fails any file over max-lines that is NOT suppressed, so this - # ratchet forbids ADDING a new suppression (inline disable or mobile max - # bump). Existing oversized files are grandfathered in - # config/max-lines-baseline.txt, which may only shrink — new bypasses fail - # here with a clear message instead of silently growing the debt. - - name: Enforce max-lines ratchet (no new bypasses) + - name: Enforce max-lines ratchet run: pnpm run check:max-lines-ratchet - # Why: the CLI embeds guide content while the skills CLI installs generated - # projections from the repository, so stale output would split those two truths. - name: Verify bundled skill guides run: pnpm run verify:bundled-skill-guides - name: Verify skill freshness manifest run: pnpm run verify:skill-bundle-manifest + - name: Verify localization catalog + run: pnpm run verify:localization-catalog + + - name: Verify localization coverage + run: pnpm run verify:localization-coverage + # Why: project-owned type declarations must live in .ts so tsc # actually checks them. TypeScript's skipLibCheck: true (inherited # from @electron-toolkit/tsconfig) silently widens unresolved names @@ -113,55 +81,212 @@ jobs: - name: Verify macOS entitlements run: pnpm verify:macos-entitlements - - name: Typecheck - run: pnpm typecheck + typecheck: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + + - run: pnpm typecheck + + git_compatibility: + name: Git compatibility + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies - # Why: real old Git diagnostics differ from mocked errors. Keep the - # fallback predicates executable across the baseline, transition, and - # current command shapes so a newly added flag cannot silently regress. - name: Verify Git binary compatibility matrix run: | - archive="$RUNNER_TEMP/git-2.25.5.tar.gz" - source="$RUNNER_TEMP/git-2.25.5" - curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" - echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ - | sha256sum --check - mkdir -p "$source" - tar -xzf "$archive" -C "$source" --strip-components=1 - make -C "$source" -j2 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git - ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \ - pnpm exec vitest run --config config/vitest.config.ts \ - src/shared/git-binary-compatibility.test.ts + pids=() + ( + archive="$RUNNER_TEMP/git-2.25.5.tar.gz" + source="$RUNNER_TEMP/git-2.25.5" + curl -fsSL https://www.kernel.org/pub/software/scm/git/git-2.25.5.tar.gz -o "$archive" + echo "41662c52fc16fec4963bfc41075e71f8ead6b5e386797eb6f9a1111ff95a8ddf $archive" \ + | sha256sum --check + mkdir -p "$source" + tar -xzf "$archive" -C "$source" --strip-components=1 + make -C "$source" -j"$(nproc)" \ + NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PYTHON=YesPlease git + ORCA_GIT_COMPAT_BINARY="$source/git" ORCA_GIT_COMPAT_VERSION="2.25.5" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/shared/git-binary-compatibility.test.ts + ) & + pids+=("$!") for spec in \ "alpine/git:edge-2.38.1|2.38.1" \ "alpine/git:v2.49.1|2.49.1"; do - image="${spec%%|*}" - version="${spec#*|}" - ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \ - pnpm exec vitest run --config config/vitest.config.ts \ - src/shared/git-binary-compatibility.test.ts + ( + image="${spec%%|*}" + version="${spec#*|}" + ORCA_GIT_COMPAT_IMAGE="$image" ORCA_GIT_COMPAT_VERSION="$version" \ + pnpm exec vitest run --config config/vitest.config.ts \ + src/shared/git-binary-compatibility.test.ts + ) & + pids+=("$!") done - # Why: postinstall rebuilds better-sqlite3 for Electron's ABI via - # @electron/rebuild, but vitest runs under system Node.js. Rebuild - # it for Node so orchestration tests can load the native module. - - name: Rebuild better-sqlite3 for Node - run: pnpm rebuild better-sqlite3 + status=0 + for pid in "${pids[@]}"; do + wait "$pid" || status=1 + done + exit "$status" + + shell_contracts: + name: shell contracts + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Install zsh + run: sudo apt-get update && sudo apt-get install -y zsh + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + + - name: Test real shell contracts + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + src/main/daemon/shell-ready.test.ts \ + src/main/daemon/node-pty-fd-leak.test.ts \ + src/main/providers/local-pty-shell-ready.test.ts \ + src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + src/shared/posix-command-path-lookup.test.ts + + test: + name: tests ${{ matrix.shard }}/${{ strategy.job-total }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16] + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node - # Why: install intentionally blocks Electron's package postinstall, but - # some unit tests import `electron` under Node and require path.txt. - name: Install Electron package binary for tests run: node config/scripts/install-electron-package-binary.mjs - - name: Test - run: pnpm test + - name: Test shard + run: | + pnpm exec vitest run --config config/vitest.config.ts \ + --exclude=src/main/daemon/shell-ready.test.ts \ + --exclude=src/main/daemon/node-pty-fd-leak.test.ts \ + --exclude=src/main/providers/local-pty-shell-ready.test.ts \ + --exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \ + --exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \ + --exclude=src/shared/posix-command-path-lookup.test.ts \ + --shard=${{ matrix.shard }}/${{ strategy.job-total }} - - name: Build unpacked app - run: pnpm build:unpack + package: + name: package + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Cache electron-builder downloads + uses: actions/cache@v5 + with: + path: | + ~/.cache/electron + ~/.cache/electron-builder + key: electron-builder-linux-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + electron-builder-linux- + + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: electron + + - name: Build package inputs + run: | + status=0 + pnpm run build:cli || status=1 + + scripts=(build:relay build:electron-vite:parallel) + pids=() + for script in "${scripts[@]}"; do + pnpm run "$script" & + pids+=("$!") + done + + for pid in "${pids[@]}"; do + wait "$pid" || status=1 + done + exit "$status" + + - name: Project web client from renderer build + run: pnpm run build:web-from-renderer + + - name: Build native components + run: pnpm run build:native + + - name: Package unpacked app + env: + ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' + run: pnpm exec electron-builder --config config/electron-builder.config.cjs --dir - # Why: the packaged CLI runs outside Electron's asar integration, so - # bare runtime imports must be present in the package itself. Run from a - # temp copy so Node cannot mask missing package deps with repo node_modules. - name: Smoke packaged CLI run: node config/scripts/smoke-packaged-cli.mjs --app-dir=dist/linux-unpacked + + verify: + if: always() + needs: + - static_analysis + - typecheck + - git_compatibility + - shell_contracts + - test + - package + runs-on: ubuntu-latest + + steps: + - name: Require successful checks + env: + STATIC_ANALYSIS: ${{ needs.static_analysis.result }} + TYPECHECK: ${{ needs.typecheck.result }} + GIT_COMPATIBILITY: ${{ needs.git_compatibility.result }} + SHELL_CONTRACTS: ${{ needs.shell_contracts.result }} + TEST: ${{ needs.test.result }} + PACKAGE: ${{ needs.package.result }} + run: | + for result in \ + "$STATIC_ANALYSIS" \ + "$TYPECHECK" \ + "$GIT_COMPATIBILITY" \ + "$SHELL_CONTRACTS" \ + "$TEST" \ + "$PACKAGE"; do + if [ "$result" != "success" ]; then + exit 1 + fi + done diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 730583e3404..3848f15e861 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -1499,7 +1499,6 @@ jobs: Write-Warning 'Restored pre-rebuild installer; this release ships with unsigned inner binaries.' } # ── End Windows inner-binary signing ─────────────────────────────── - - name: Upload unsigned Windows installer for SignPath if: matrix.platform == 'win' id: upload-unsigned-windows-installer @@ -1644,8 +1643,41 @@ jobs: INNER_SIGNING_COMPLETED: ${{ steps.rebuild-nsis-signed.outcome == 'success' }} run: | $required = $env:ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED -eq 'true' + + # Why: a fail-open gate that writes nothing is indistinguishable from a + # gate that passed. Always leave a verdict in the evidence artifact and + # the job summary so a silent degradation is visible (#6487). + # Why best-effort: while warn-only, a disk-full or permission error + # writing the verdict must not become the thing that fails the release. + function Add-GateEvidence([string]$line) { + try { + Add-Content -Path 'inner-signing-evidence.txt' -Value "`n$line" -ErrorAction Stop + } catch { + Write-Host "::warning::Could not append to the inner-signing evidence file: $_" + } + } + + function Add-GateSummary([string]$verdict) { + if (-not $env:GITHUB_STEP_SUMMARY) { return } + try { + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "**Windows inner-binary signing:** $verdict" -ErrorAction Stop + } catch { + Write-Host "::warning::Could not write inner-signing verdict to the job summary: $_" + } + } + + function Write-GateVerdict([string]$verdict) { + try { + Set-Content -Path 'inner-signing-evidence.txt' -Value $verdict -ErrorAction Stop + } catch { + Write-Host "::warning::Could not persist inner-signing verdict: $_" + } + Add-GateSummary $verdict + } + if ($env:INNER_SIGNING_COMPLETED -ne 'true') { $message = 'Windows inner-binary signing did not complete; this release ships unsigned inner binaries (fail-open, issue #7785).' + Write-GateVerdict "NOT VERIFIED — $message" if ($required) { throw $message } Write-Host "::warning::$message" exit 0 @@ -1654,13 +1686,28 @@ jobs: # Why try/catch: while the gate is warn-only, even an unexpected # script error (extraction hiccup, missing file) must not block # the release — only the flip to required makes failures fatal. + # Why tracked separately: a required-mode signature failure must not be + # rewritten as ERRORED by the catch below, which would replace the + # per-file report with an exception string and lose the diagnostics. + $policyFailure = $null + try { $report = New-Object System.Collections.Generic.List[string] $failures = New-Object System.Collections.Generic.List[string] # Why: verify the files a user actually gets on disk, not the build # tree — 7z parses the NSIS exe directly as its embedded payload. - $7za = 'node_modules/7zip-bin/win/x64/7za.exe' + # Resolve 7za via app-builder-lib; electron-builder 26.9+ dropped the + # bundled 7zip-bin package the old hardcoded path relied on (#6487). + $7zaOutput = node config/scripts/resolve-7za-path.mjs + $7zaExitCode = $LASTEXITCODE + if ($7zaExitCode -ne 0) { + throw "The 7za resolver exited with code $7zaExitCode for the inner-binary evidence gate." + } + $7za = ($7zaOutput | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) { + throw "The 7za resolver returned an invalid path for the inner-binary evidence gate: $7za" + } New-Item -ItemType Directory -Path inner-evidence-extract -Force | Out-Null & $7za x 'dist/orca-windows-setup.exe' '-oinner-evidence-extract' -y | Out-Null @@ -1694,16 +1741,32 @@ jobs: if ($failures.Count -gt 0) { $failures | ForEach-Object { Write-Host "::warning::$_" } $message = "Windows inner-binary evidence gate found $($failures.Count) problems." - if ($required) { throw $message } - Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'." + # Why assigned before any I/O: a write that throws here would reach + # the catch with $policyFailure still null, so a required-mode + # signature failure would be re-reported as ERRORED and the per-file + # report overwritten — the exact masking the hoist exists to prevent. + if ($required) { + $policyFailure = $message + } else { + Write-Host "::warning::$message Fail-open until ORCA_WINDOWS_INNER_SIGNATURE_REQUIRED is 'true'." + } + Add-GateEvidence "VERDICT: FAILED — $message" + Add-GateSummary "FAILED — $message" } else { - Write-Host "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation." + $ok = "All $($targets.Count) inner binaries in the shipped installer are signed by SignPath Foundation." + Add-GateEvidence "VERDICT: PASSED — $ok" + Add-GateSummary "PASSED — $ok" + Write-Host $ok } } catch { + Write-GateVerdict "ERRORED — $_" if ($required) { throw } Write-Host "::warning::Windows inner-binary evidence gate errored: $_ (fail-open, issue #7785)." } + # Outside the catch so the FAILED evidence report survives intact. + if ($policyFailure) { throw $policyFailure } + - name: Upload Windows inner signing evidence if: always() && matrix.platform == 'win' uses: actions/upload-artifact@v7 diff --git a/.github/workflows/win-crash-survival-e2e.yml b/.github/workflows/win-crash-survival-e2e.yml index e4c388ceb18..7dc5f0e219b 100644 --- a/.github/workflows/win-crash-survival-e2e.yml +++ b/.github/workflows/win-crash-survival-e2e.yml @@ -27,7 +27,11 @@ on: - 'config/patches/**' - 'config/scripts/ensure-native-runtime.mjs' - 'config/scripts/rebuild-native-deps.mjs' + - 'config/scripts/verify-packaged-plugin-resources.cjs' - 'native/**' + # Byte-hashed at package time, and CRLF-sensitive on Windows. + - '.gitattributes' + - 'resources/plugins/**' - 'resources/win32/**' - 'src/main/daemon/**' - 'src/main/index.ts' diff --git a/.github/workflows/windows-signing-rehearsal.yml b/.github/workflows/windows-signing-rehearsal.yml index 4ca080d803d..73d66e466ec 100644 --- a/.github/workflows/windows-signing-rehearsal.yml +++ b/.github/workflows/windows-signing-rehearsal.yml @@ -325,7 +325,16 @@ jobs: # Extract the signed installer and verify the files a user actually # gets on disk — including the exact file from issue #7785. - $7za = 'node_modules/7zip-bin/win/x64/7za.exe' + # electron-builder 26.9+ removed the bundled 7zip-bin package (#6487). + $7zaOutput = node config/scripts/resolve-7za-path.mjs + $7zaExitCode = $LASTEXITCODE + if ($7zaExitCode -ne 0) { + throw "The 7za resolver exited with code $7zaExitCode for the signing rehearsal." + } + $7za = ($7zaOutput | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf)) { + throw "The 7za resolver returned an invalid path for the signing rehearsal: $7za" + } New-Item -ItemType Directory -Path extracted-app -Force | Out-Null & $7za x 'dist/orca-windows-setup.exe' '-oextracted-app' -y | Out-Null diff --git a/.oxlintrc.json b/.oxlintrc.json index f053732b6d3..4b0cbed3e53 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -49,6 +49,23 @@ ], "curly": "error", "no-unneeded-ternary": "error", + "no-restricted-imports": [ + "error", + { + "paths": [ + { + "name": "@linear/sdk", + "allowTypeImports": true, + "message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts." + }, + { + "name": "qrcode", + "allowTypeImports": true, + "message": "qrcode is only reachable from mobile pairing; a static import parses it at launch for everyone. Use `import type` plus `await import('qrcode')` at the call site." + } + ] + } + ], "no-useless-return": "error", "prefer-template": "error", "unicorn/consistent-empty-array-spread": "error", diff --git a/ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md b/ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 00000000000..57c00ba3a8b --- /dev/null +++ b/ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,1787 @@ +# Orca Orchestration Implementation Checklist + +This is the durable implementation ledger for the orchestration primitives proposal. Update it in +the same change that implements, removes, or materially revises an item. The design source is +`docs/orchestration-primitives.html`; keep it synchronized with this checklist. + +## How to use this file + +- Check an item only after its implementation and proportionate tests are complete. +- If an item changes meaning, edit the checklist and add a dated decision-log entry explaining why. +- After every implementation session, append a progress-log entry with files, tests, findings, and + the next concrete step. +- Do not mark a phase complete while any acceptance test in that phase remains open. +- Preserve the non-goals. A new subsystem requires separate evidence and a separate proposal. + +Status meanings: + +- `[ ]` not started or not proven +- `[x]` implemented and verified +- `DEFERRED` deliberately outside the current implementation sequence + +## Current summary + +- [x] Fresh primitive-oriented design written and repeatedly reviewed. +- [x] Product UI changes explicitly excluded. +- [x] Current orchestration skill now teaches setup-run for new worktrees, batch processing, + `agentTerminalHandle` preference, and the custom-argv setup-policy limitation. +- [x] Phase 0 command/skill compatibility work complete. +- [x] Phase 1 local Run, mailbox, lifecycle, and idempotency primitives complete. +- [x] Phase 2 same-server composed worker lifecycle complete. +- [x] Phase 3 connected-server federation complete. +- [x] Revalidate Phase 3 after the 2026-07-24 post-rebase dogfood exposed a renderer-adoption + process-identity regression. +- [x] Phase 4 structured worker output is implemented with passing automated coverage and physical + local, mixed-version, restart, disconnect, and Windows-home to Mac-worker evidence; optional + symmetric acceptance checks remain tracked below. +- [x] Hard-cutover migration fence implemented and locally verified; branch-head CI remains the + final remote check. + +## Scope invariants + +- [x] Agents choose decomposition, topology, placement, parallelism, and recovery strategy. +- [x] Low-level worktree, terminal, setup, and handoff commands remain independently usable. +- [x] Every composed mutation with external effects returns explicit effects and an honest outcome; + control-plane-only mutations return their exact resource receipt. +- [x] Worker assertions remain labeled as worker reports, not Orca-verified correctness. +- [x] Silence alone never proves worker death or triggers replacement. +- [x] Multi-server Runs use one authoritative Run home and connected worker servers. +- [x] Runtime/server/host identity mechanics remain hidden from ordinary agent commands. + +Explicit non-goals: + +- [x] No product dashboard, Run UI, badges, or coordinator chat UI. +- [x] No scheduler, automatic placement, capacity allocator, fairness, or priority aging. +- [x] No automatic retry or replacement based on silence. +- [x] No commit, branch, merge, integration, or target-ref tracking. +- [x] No filesystem read-only/writer enforcement. +- [x] No generalized ACL, organization, role, or worker-profile system. +- [x] No replicated Run database, leader election, or automatic Run-home failover. +- [x] No dead-letter/poison-message workflow. +- [x] No universal provider-session or transcript framework. + +## Phase 0 — Vocabulary and current-agent ergonomics + +### Command compatibility + +- [x] Remove or rename the existing scheduler-like `orca orchestration run --spec` before shipping + lightweight `run-*` commands. +- [x] Publish the canonical command map in CLI help. +- [x] Define aliases or explicit deprecations for any renamed current command. +- [x] Keep current flat task commands unless a separately justified CLI migration changes them. +- [x] Require explicit destructive reset scope; bare reset must not imply reset-all. + +### Skill and recipes + +- [x] Teach that `check --wait` returns a message batch, not one message. +- [x] Teach processing every returned message and waiting until expected Dispatches settle. +- [x] Document crash-safe explicit acknowledgment and redelivery of an unacknowledged batch. +- [x] Prefer `agentTerminalHandle`, then legacy `startupTerminal.handle`, then exact terminal-list + resolution. +- [x] Pass `--setup run` for new worktrees by default. +- [x] State a concrete reason before using `--setup skip` or `--setup inherit`. +- [x] Preserve `start-immediately` as the normal setup/agent startup policy. +- [x] Warn that the two-step custom-argv launch cannot preserve explicit `wait-for-setup`. +- [x] Add executable current-version recipes for local fan-out, new worktrees, completion/failure, + questions, timeout recovery, and restart limitations. +- [x] Ensure installed, bundled, repository, and generated orchestration skill copies stay in sync. + +### Phase 0 acceptance + +- [x] An agent reading only CLI help and the skill can select current versus new worktree correctly. +- [x] The agent starts all independent workers before blocking. +- [x] The agent cannot mistake the old scheduler Run for a lightweight namespace Run. +- [x] Recipes contain only commands supported by the matching shipped CLI version. + +## Phase 1 — Run, mailbox, lifecycle, and durable mutation receipts + +### Lightweight Run + +- [x] Add a Run table with stable Run ID, objective, created/updated timestamps, and home database. +- [x] Add mandatory Run association to new Tasks, Dispatches, Messages, deliveries, and questions. +- [x] Migrate pre-Run orchestration rows into one unbound, inspect-only legacy Run. +- [x] Keep old scheduler-run storage distinct from lightweight Runs. +- [x] Implement `run-create`, `run-use`, `run-current`, `run-list`, and `run-show`. +- [x] Bind a coordinator pane explicitly; never infer a Run from a worktree or sole candidate. +- [x] Store one active mailbox consumer generation per Run. +- [x] Rebinding fences the old consumer and cancels its active waiter. +- [x] Do not implement Run archive/delete in V1. + +### Logical routing and prompt safety + +- [x] Add stable `run:` and exact `dispatch:` message recipients. +- [x] Do not add a task-recipient retargeting rule in V1. +- [x] Make send, ask, reply, completion, heartbeat, and runtime notices inbox-only. +- [x] Ensure only explicit dispatch injection and terminal-send operations can modify terminal input. +- [x] Define send success as durable acceptance, not observation or action. +- [x] Rename or remove `check --inject` if its name implies remote delivery. +- [x] Reject explicit Run/recipient targets from federated workers when the only valid destination is + their authenticated Run home. + +### Crash-safe inbox consumption + +- [x] Add one FIFO mailbox sequence. +- [x] Bound each actionable delivery to 50 messages. +- [x] Allow one outstanding Delivery and one active actionable waiter per Run mailbox. +- [x] Return the identical Delivery ID and batch until acknowledgment. +- [x] Implement whole-batch idempotent acknowledgment. +- [x] Bind Delivery acknowledgment to the current consumer generation. +- [x] Implement atomic `ack -> check -> register waiter`. +- [x] Keep peek/all/history modes read-only. +- [x] Treat type filters as wake predicates only; return the oldest full actionable batch. +- [x] Return typed timeout, cancelled, connection-lost, waiter-exists, stale-delivery, and + consumer-fenced outcomes. +- [x] Preserve unacknowledged mail across client and Orca process restart. + +### Truthful lifecycle + +- [x] Require `outcome=succeeded|failed` on terminal worker reports. +- [x] Map authenticated succeeded reports to Dispatch succeeded and Task completed. +- [x] Map authenticated failed reports to Dispatch failed and Task failed. +- [x] Persist stale/foreign reports as history without lifecycle mutation. +- [x] Reject malformed lifecycle transitions with typed missing/invalid fields. +- [x] Label result provenance as `worker_report`. +- [x] Apply every terminal transition as one transactional compare-and-set. +- [x] First committed completion, stop fence, or abandon wins. +- [x] Make duplicate identical completion idempotent. + +### Questions + +- [x] Model a question as durable message/thread state, not a task gate. +- [x] Default ask from an active Dispatch to its owning Run mailbox. +- [x] Record one idempotent first answer from the current Run consumer generation. +- [x] Reject conflicting later answers. +- [x] Resume by original message ID after timeout or disconnect. +- [x] Recover a lost ask-acceptance response through the same mutation retry receipt. +- [x] Close pending questions when their Dispatch stops or is abandoned. +- [x] Wake closed question waits with `dispatch_inactive`. + +### Narrow pane authority + +- [x] Mint an unforgeable per-Dispatch capability at lifecycle injection. +- [x] Carry the capability outside user-controlled request parameters on native, WSL, and SSH CLI + bridges. +- [x] Persist only its verifier or secure-store reference. +- [x] Verify capability, exact managed pane, Dispatch ID, and process incarnation for lifecycle calls. +- [x] Revoke/fence the capability on stop, abandon, or replacement. +- [x] Do not generalize this into user/role access control. + +### Durable mutation ledger + +- [x] Let clients retain/reuse one opaque retry request ID after unknown acceptance. +- [x] Before effects, persist authenticated caller/peer, request ID, canonical payload hash, + operation state, and receipt. +- [x] Join concurrent identical mutations or return the recorded result. +- [x] Return `request_mismatch` for the same request ID with a changed payload. +- [x] Cover Run/Task creation, send, ask, reply, acknowledgment, start, stop, and abandon. +- [x] Persist dedupe receipts across Orca restart. + +### Phase 1 acceptance + +- [x] Two Runs on the same runtime never mix tasks or mail. +- [x] An old coordinator cannot acknowledge or reply after `run-use` fences it. +- [x] A returned but unacknowledged batch is replayed after client/runtime restart. +- [x] Success, failure, stale completion, malformed completion, and duplicate completion tests pass. +- [x] Ask timeout/resume, same reply replay, conflicting reply, and stopped-Dispatch tests pass. +- [x] A forged pane/handle field cannot mutate lifecycle state. + +## Phase 2 — Same-server composed worker lifecycle + +### Command grammar and topology + +- [x] Implement `worker-start`, `worker-show`, `worker-read`, `worker-stop`, and + `worker-abandon` for workers owned by the Run home. +- [x] Current worktree creates one fresh agent terminal unless `--terminal` is explicit. +- [x] Named existing worktree creates one fresh agent terminal unless reuse is explicit. +- [x] Current/existing worktrees do not rerun creation-time setup or configured tabs. +- [x] New child worktree uses agent-first creation and reuses its returned agent terminal. +- [x] New top-level worktree uses agent-first creation with independent Orca lineage. +- [x] Reject child/top-level creation for folder projects before effects; use current/existing folder + workspaces instead. +- [x] Pass the supported exact repo, base, lineage, display/comment metadata, and setup options to + the existing worktree primitive rather than duplicating policy. `--on` owns connected-server + placement; project/host convenience selection remains on low-level `worktree create`. +- [x] Require a configured agent launcher before any mutation. +- [x] Reject selector/option conflicts before effects for current/existing worktrees. + +### Setup and startup + +- [x] Omitted setup on a new worktree resolves to `run` for orchestration starts. +- [x] No configured setup hook resolves to `not_configured`, not failure. +- [x] Preserve repository `setupAgentStartupPolicy`. +- [x] Preserve whether setup came from an explicit request or Orca's orchestration default across + connected-server starts. +- [x] Default `start-immediately` launches setup and agent side by side. +- [x] Under `start-immediately`, setup outcome never gates Dispatch readiness regardless of when + it is observed. +- [x] Track the setup command's exit code without waiting for its interactive terminal shell to + exit or closing the setup tab. +- [x] Register the completion observer before replaying bounded recent output so fast local setup + commands cannot finish in an observation gap. +- [x] Carry the exact created setup terminal handle; never infer setup identity from a display + title shared with configured tabs or split panes. +- [x] Scope completion signals to a private per-invocation token and preserve uncertain terminal + outcomes as running rather than converting a disconnect into setup failure. +- [x] The return receipt contains the latest setup state. +- [x] Only post-return setup state changes emit a typed setup notice. +- [x] Setup failure never automatically stops or fails an already-ready worker. +- [x] Explicit `wait-for-setup` completes setup successfully before agent launch and task injection. +- [x] Under `wait-for-setup`, setup failure produces start failure before task delivery. +- [x] Custom-argv two-step launch is rejected or clearly unsupported under `wait-for-setup`. + +### Start operation and receipts + +- [x] In one transaction, create a starting Dispatch, move the Task, and record the mutation request. +- [x] Persist the accepted Dispatch ID in a pending start receipt so restart recovery returns an + exact `worker-show` command. +- [x] Persist before/after stage receipts around irreversible effects. +- [x] Return only `ready`, `failed`, or `outcome_unknown`. +- [x] Define ready as TUI idle, durable Dispatch attachment, and accepted lifecycle/task input. +- [x] Echo effective timeout, setup startup policy, defaults, and resolution sources. +- [x] Enumerate every effect: worktree, setup, agent terminal, setup terminal, each configured + terminal pane, and dispatch input; include exact tab/leaf identity when available. +- [x] Persist accepted dispatch input atomically with the ready transition so later setup refreshes + cannot erase it. +- [x] Tag every terminal effect with role and created/reused action. +- [x] Report setup as running only after its exact PTY spawn receipt is durable. +- [x] List every residual resource on failure or unknown outcome. +- [x] Never claim an effect was created before it exists. +- [x] Do not add a background provisioning executor; intentionally launched setup may continue as + its receipt states. + +### Dispatch and Task state machine + +- [x] Implement starting, ready, start-unknown, failed, succeeded, stopping, stop-unknown, stopped, + and abandoned Dispatch states. +- [x] Block the Task while start/stop outcome remains unknown. +- [x] Allow semantic `--retry-of` only from explicit failed, stopped, abandoned, or proven no-effect + states. +- [x] Require the replacement to repeat its intended placement and agent/terminal choice; do not + silently inherit a prior attempt's topology. +- [x] Reject unsafe retry without mutation. +- [x] Completed Tasks require a follow-up Task rather than retry. + +### Show, read, stop, and abandon + +- [x] Route operations by Dispatch ID after start; do not require resource IDs again. +- [x] Implement V1 `worker-read` as a thin route to bounded terminal-read. +- [x] Preserve cursor, limit, terminal status, and limited/truncated fields. +- [x] Stop fences lifecycle and blocks the Task in one home-side compare-and-set. +- [x] Stop affects only the supervised agent terminal/process. +- [x] Never delete the worktree, setup terminal, configured tabs, or unrelated processes. +- [x] Return stopped, already-settled, failed, and stop-unknown receipts truthfully. +- [x] Abandon performs no remote/process action, retains possibly-live resources, and enables a + warned replacement. + +### Same-server recovery tests + +- [x] Current, existing, child, top-level, explicit-terminal, and configured-tab starts pass. +- [x] Setup run/skip/inherit and start-immediately/wait-for-setup combinations pass. +- [x] Trust/update prompt, setup failure, terminal failure, and task-input failure receipts pass. +- [x] Crash before effect, after possible effect/before receipt, and after durable receipt are + distinguishable as failed/no-residual, outcome-unknown, and failed-with-residual respectively. +- [x] Stop/completion races preserve the first committed terminal transition. +- [x] Restart never adopts a same-looking pane or process incarnation. + +## Phase 3 — Connected Orca server federation + +### Placement and identity + +- [x] Add worker-only `--on ` without changing global `--environment` meaning. +- [x] Default worker placement to the Run home. +- [x] Require `--on` for remote existing worktree/terminal selectors in V1. +- [x] Resolve remote resources through explicit read-only discovery; never guess by name/path. +- [x] Return `server_required`, `worktree_not_found_on_server`, and + `terminal_worktree_mismatch` before related worker effects; treat a mismatched remote + Dispatch/home receipt as `resource_server_mismatch` and never adopt it. +- [x] Pin each remote Dispatch to the authenticated worker-server public-key fingerprint. +- [x] Store runtime ID only as a process epoch, never durable server identity. +- [x] Return `peer_changed` with no effect if a saved environment is re-paired to a different server. +- [x] Preserve a routing tombstone when an environment with nonterminal Dispatches is removed. + +### Remote Dispatch attachment + +- [x] Persist a narrow attachment on the worker server before task input. +- [x] Store home peer identity, Dispatch capability verifier, stable pane/process incarnation, + resource receipts, protocol version, and relay cursors. +- [x] Do not copy the Run DAG/database to the worker server. +- [x] Protect attachment credentials/database/WAL/SHM as current-user-only on macOS, Linux, and + Windows. + +### Bidirectional relay + +- [x] Use the existing authenticated saved-environment connection; require no public callback or + reciprocal pairing. +- [x] Run a Run-home subscription/pull service for active remote Dispatches. +- [x] Persist worker-to-home lifecycle/questions until home import acknowledgment. +- [x] Persist home-to-worker replies/control mail until worker import acknowledgment. +- [x] Route coordinator `send --to dispatch:` through that same durable relay and wake the + exact remote worker's local `check --wait`. +- [x] Key relay items by pinned peer, Dispatch ID, direction, monotonic sequence, and a + 128-bit-or-stronger message ID. +- [x] Import only contiguous source sequences; buffer/reject gaps. +- [x] Acknowledge only the highest contiguous committed sequence. +- [x] Assign ordinary Run-mailbox order only at home import. +- [x] Apply lifecycle transition and message import in one transaction before acknowledgment. +- [x] Enforce per-message and per-Dispatch count/byte quotas. +- [x] Coalesce heartbeats and reserve room for one terminal lifecycle report. +- [x] Return `relay_quota_exceeded`; do not add a dead-letter system. + +### Federated control and recovery + +- [x] Route show/read/stop/retry by Dispatch receipt; agents do not repeat `--on`. +- [x] Forward the same application retry request ID across home and worker server. +- [x] Return typed unknown outcome with last durable stage and exact next commands. +- [x] Reconcile a lost federated stop response from a later authoritative stopped receipt. +- [x] Treat abandonment of a superseded Dispatch as a no-op for the replacement Task. +- [x] Preserve and relay post-return federated setup evidence without changing worker lifecycle. +- [x] Recreate active relay subscriptions after Run-home restart. +- [x] Preserve worker attachment and relay state after worker-server restart. +- [x] Report running only when pane and process incarnation match after restart. +- [x] One disconnected worker server must not block local or other-server inbox delivery. +- [x] No automatic worker replacement on disconnect or silence. + +### Capability negotiation + +- [x] Advertise one aggregate `orchestrationFederationV1` control-plane capability. +- [x] Pin peer fingerprint and protocol version in the durable operation record. +- [x] Revalidate them inside the worker-side mutation, not only in a preflight probe. +- [x] Return `capability_unsupported` before Dispatch/resource/prompt effects. +- [x] Keep host/Git/setup validation inside existing primitives rather than a generalized capability + matrix. + +### Federation scenario matrix + +- [x] Post-rebase physical Mac Run home -> Windows worker preserves exact process identity through + renderer adoption, routed read, heartbeat, question/reply, completion, and stop. +- [x] Mac Run home -> Windows worker: start, completion, failure, question/reply, read, and stop. +- [x] Windows Run home -> Mac worker: the same flows through a saved Mac pairing. +- [x] Native, WSL, SSH, and relay-backed execution-host paths preserve ownership and CLI capability. +- [x] Run home restarts alone; worker server restarts alone; both restart. +- [x] Disconnect before send proves no effect. +- [x] Disconnect after possible acceptance returns unknown and deduplicates exact retry. +- [x] Duplicate and reordered relay frames/acknowledgments converge without loss or duplication. +- [x] Re-pair/key change cannot retarget an active Dispatch. +- [x] Same-looking handles/resources on two servers never cross-route. +- [x] Mixed server versions fail before effects. +- [x] Windows PowerShell quoting, Windows paths, WSL environment propagation, and SSH bridge + allowlists pass. + +## Phase 4 — Structured worker output + +- [x] Reuse Orca's exact pane/process-to-provider-session association; do not create a second status + system. +- [x] Keep bounded terminal-read as the universal fallback. +- [x] Read only Codex, Claude/OpenClaude, and Grok transcripts supported by the existing + Native Chat decoders. +- [x] Never guess the latest session by current working directory, terminal title, logo, or agent + type. +- [x] Pin Dispatch, process, source, and provider session for the full opaque cursor chain. +- [x] Preserve the existing structured native-chat message/block representation and emit bounded + parsing/clipping warnings. +- [x] Label terminal fallback and its reason explicitly. +- [x] Read transcripts on the worker-owning server and never serialize their filesystem paths. +- [x] Fall back to the legacy federated terminal-read RPC when a connected server lacks the additive + structured-read method. +- [x] Cover exact selection, sibling-session isolation, source changes, malformed input, limits, + path privacy, CLI rendering, and mixed-version fallback with automated tests. +- [x] Physically verify local Codex, two same-worktree Codex sessions, cursor continuation, + provider-session replacement, explicit terminal selection, and safe Run-home restart behavior. +- [x] Physically verify Mac Run home -> older Windows worker terminal fallback, including an opaque + continuation cursor and explicit transcript-required failure. +- [ ] Physically verify hooks-disabled automatic fallback and disconnect/reconnect. +- [ ] Physically verify exact structured Mac-to-Windows and Windows-to-Mac reads after both worker + servers run the new additive method. +- [x] Do not add resume, live-stream control, session exclusivity, or a universal transcript ontology. + +## Migration — Hard cutover from pre-Run orchestration + +### Contract and effect fence + +- [x] Add one orchestration contract version and one advertised runtime capability without changing + the global runtime protocol. +- [x] Keep one shared mutation/read classifier for CLI, runtime dispatch, durable receipts, and + connected-server calls. +- [x] Require the contract before parameter parsing, mutation receipts, database writes, prompt + injection, process actions, or connected-server mutations. +- [x] Preflight local and paired runtime capabilities before a new CLI sends a mutation. +- [x] Carry the contract through native Unix/named-pipe, WebSocket, and connected-server envelopes. +- [x] Retire `coordinator-start`, `coordinator-stop`, `run`, and `run-stop` before RPC effects. +- [x] Do not add a compatibility executor, automatic rewrite, legacy scheduler, or in-flight drain. + +### Agent recovery and legacy inspection + +- [x] Return `effectsApplied=false`, structured guide metadata, and executable argument-only + `skills get orchestration --full` recovery. +- [x] Attach the same guide recovery to no-bound-Run and missing worker outcome errors. +- [x] Preserve explicit read-only Run, task, inbox, Dispatch, gate, and terminal inspection. +- [x] Allow `task-list --run run_legacy_local` without binding the legacy Run. +- [x] Keep default/actionable check and acknowledgment fenced; only explicit peek/all history reads + may inspect legacy mail. +- [x] Document that active pre-upgrade agents keep running as processes but are unsupervised and + must be inspected before replacement. +- [x] Remove the legacy scheduler recipe from the version-matched full orchestration guide. + +### Migration acceptance + +- [x] Missing/wrong contract rejects every classified mutation before parsing, receipt, and effect. +- [x] Current-contract mutations still execute and retain durable retry receipts. +- [x] Read-only inspection works without a contract and does not consume legacy data. +- [x] Local and remote clients reject a runtime missing the contract capability before mutation. +- [x] Native and encrypted WebSocket transports preserve the contract field. +- [x] Old `worker_done` leaves message, Task, and Dispatch state unchanged. +- [x] Human and JSON errors preserve no-effects and guide-reload recovery. +- [x] `skills get orchestration --full` remains runtime-independent and generated guides stay in + sync. +- [ ] Branch-head CI passes after the verified migration commit is pushed. Local orchestration, + repository tests, typechecks, reliability gates, and production builds pass. + +## Cross-cutting quality gates + +### Persistence and transactions + +- [x] Define process-crash durability separately from sudden-power-loss durability. +- [x] Keep SQLite WAL + `synchronous=NORMAL` for the documented process-crash guarantee; require a + separate policy change before promising sudden-power-loss durability. +- [x] Keep lifecycle import, terminal transitions, and acknowledgment transaction boundaries explicit. +- [x] Exercise migrations from existing task/message/dispatch/scheduler-run data. + +### Cross-platform + +- [x] Native macOS, Linux, and Windows tests cover each new CLI/RPC contract. +- [x] WSL and SSH host identity/capability state is scoped to the actual execution host. +- [x] Paths use platform utilities; examples are PowerShell/cmd/POSIX safe. +- [x] Named-pipe, Unix-socket, WebSocket, WSL, and SSH bridges carry Dispatch capabilities safely. + +### Documentation + +- [x] CLI help owns exact flags, selectors, defaults, outcome fields, and exit-code behavior; typed + RPC errors remain the machine-readable error contract. +- [x] The skill owns short decision recipes and common misuses, not protocol internals. +- [x] Every shipped phase updates this checklist and adds a progress-log entry. +- [x] The ignored HTML proposal and this tracked checklist remain semantically synchronized. + +## Findings and decision log + +### 2026-07-22 — Phase 2 closure without option-surface creep + +- `worker-start` passes exact repository, base, child/top-level lineage, display/comment metadata, + and setup choices into the existing worktree primitive. It does not duplicate `worktree create`'s + project/host convenience resolver: `--on` already names the connected Orca server and `--repo` + names the repository on that server. +- A gated setup receipt becomes `succeeded` only after the agent wrapper proves setup completed. A + confirmed spawn/script failure fails before task input; timeout keeps `running` because silence is + not failure. +- The existing single durable worker row is the operation stage journal. A pre-effect failure has no + residuals, possible acceptance before receipt is unknown, and later failure after a durable effect + lists exact residual resources. No background saga executor or general effect engine was added. +- This earlier Phase 4 deferral was based on an incomplete audit. Orca already retained an exact + pane-scoped provider-session association from agent hooks; the narrow implementation now exposes + it to the worker-owning runtime and still falls back when that evidence is absent. + +### 2026-07-22 — Final setup/startup review + +- Keep setup-run as the new-worktree orchestration default. +- Preserve Orca's existing `start-immediately` default: setup and agent run side by side. +- Only explicit `wait-for-setup` gates agent launch/task delivery. +- A custom-argv two-step terminal launch cannot preserve wait-for-setup and must not silently bypass + it. +- Receipts must enumerate role-tagged agent, setup, and configured terminals; a boolean + `setupSpawned` is insufficient. +- Setup outcome never gates readiness under start-immediately, regardless of observation timing. + +### 2026-07-22 — Federation robustness review + +- Multi-server operation is a core requirement, not a later optional product feature. +- Use a single authoritative Run home with narrow remote Dispatch attachments and bidirectional + relay; do not replicate the Run database. +- Pin active Dispatches to authenticated peer identity so re-pairing cannot redirect work. +- Use contiguous, scoped relay sequences and bounded storage. +- Hide peer fingerprints, relay cursors, process incarnations, and capabilities from normal agents. + +### 2026-07-22 — Validation and scope boundary + +- Phase 0 and Phase 1 are complete: their command, recipe, Run, inbox, lifecycle, question, + authority, and mutation-ledger acceptance rows now have focused passing tests. +- Phase 2 remains open until the full existing-worktree/explicit-terminal/setup-policy/failure-stage + matrix is covered. Passing current/new-worktree and recovery slices are not enough to claim it. +- Phase 3 remains open until the named Mac/Windows, WSL, SSH, relay, restart, disconnect, and quoting + matrix runs on those actual paths. The in-process federation harness proves protocol behavior but + is not a substitute for cross-platform acceptance. + - At this point Phase 4 stayed deferred pending proof of an exact association. The later + structured-output audit found the existing pane-scoped hook association and superseded this + decision without adding a universal provider framework. + +### 2026-07-21 — Simplification decision + +- Replace the original broad orchestration redesign with four public concepts: Run, Task, Dispatch, + and Message. +- Keep agent-owned strategy and strong control-plane primitives. +- Remove UI, scheduler, capacity allocation, access enforcement, commit/integration tracking, + generalized provider/session abstractions, and other speculative product machinery. +- Retain Run because it provides a durable namespace and home mailbox across connected servers, not + because it schedules work. + +## Progress log + +Append new entries chronologically. Do not rewrite older entries except to correct factual errors. + +### 2026-07-22 — Checklist initialized + +- Changes: + - Created this tracked implementation ledger from the reviewed orchestration proposal. + - Recorded all phases, acceptance tests, non-goals, and review-derived invariants. + - Updated the current orchestration skill to prefer setup-run, preserve start-immediately, process + message batches, prefer `agentTerminalHandle`, and reject custom-argv wait-policy bypass. +- Files: + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` + - `skills/orchestration/SKILL.md` + - `docs/orchestration-primitives.html` (ignored design source) +- Verification: + - HTML parsed successfully with balanced tags and unique IDs. + - Final review Task completed without an architectural blocker. +- Findings: + - Implementation may begin with Phase 0. + - Phase 2 readiness/receipt work depends on the explicit setup and role-tagged-effect contracts + recorded above. +- Next: + - Finish Phase 0 command compatibility and version-matched recipes. + +### 2026-07-22 — Phase 0 command safety and vocabulary + +- Changes: + - Renamed the scheduler-like command surface to `coordinator-start` and `coordinator-stop` while + retaining `run` and `run-stop` as documented deprecated aliases. + - Updated root help and both orchestration skill sources to distinguish the legacy automatic loop + from the proposed lightweight Run namespace and to prefer the explicit task/dispatch/wait loop. + - Made `orchestration reset` require exactly one of `--all`, `--tasks`, or `--messages` before it + contacts the runtime. + - Synchronized setup, worker-terminal selection, and message-batch guidance into the canonical + guide and regenerated the bundled CLI guide. +- Files: + - `src/cli/specs/orchestration.ts` + - `src/cli/handlers/orchestration.ts` + - `src/cli/help.ts` + - `src/cli/handlers/orchestration.test.ts` + - `src/cli/index.test.ts` + - `src/main/runtime/orchestration-cli-subprocess.test.ts` + - `skill-guides/orchestration.md` + - `skills/orchestration/SKILL.md` + - `src/cli/bundled-skill-guides.ts` +- Verification: + - `pnpm vitest run --config config/vitest.config.ts src/cli/index.test.ts src/cli/handlers/orchestration.test.ts` — 199 tests passed. + - `pnpm verify:bundled-skill-guides` — passed. + - `pnpm typecheck:cli` — passed. + - `git diff --check` — passed. +- Findings: + - Keeping hidden compatibility aliases preserves existing scripts without advertising the old + scheduler noun as the normal agent path. + - Truthful success/failure recipes depend on the Phase 1 explicit lifecycle outcome; do not + document the current behavior as if a failed `worker_done` failed the Task. +- Next: + - Implement explicit succeeded/failed worker-report semantics and then finish the version-matched + Phase 0 recipes without lying about failure behavior. + +### 2026-07-22 — Truthful worker terminal outcomes + +- Changes: + - Added the structured `--outcome succeeded|failed` worker-report field to local and SSH fallback + CLI payload construction and injected preambles. + - Added one transactional compare-and-set that settles the Dispatch and Task together, promotes + dependents only on success, and replays an identical terminal outcome idempotently. + - Persisted worker result provenance, message identity, summary, files, and report path as a + labeled `worker_report` rather than an Orca-verified result. + - Converted missing, invalid, unknown, stale, mismatched, inactive, and foreign reports into + typed, high-priority audit rows without mutating lifecycle state. + - Updated the legacy automatic coordinator loop to record failed worker reports as failed tasks. +- Files: + - `src/cli/specs/orchestration.ts` + - `src/cli/handlers/orchestration.ts` + - `src/main/ssh/ssh-remote-orchestration-send.ts` + - `src/main/runtime/orchestration/types.ts` + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orchestration/lifecycle-reconciliation.ts` + - `src/main/runtime/orchestration/preamble.ts` + - `src/main/runtime/orchestration/coordinator.ts` + - Corresponding CLI, SSH, preamble, DB lifecycle, coordinator, and RPC tests/snapshots + - Both orchestration skill sources and the generated bundled guide +- Verification: + - Five focused CLI/lifecycle/preamble/coordinator/SSH test files — 124 tests passed. + - `src/main/runtime/rpc/methods/orchestration.test.ts` — 114 tests passed. + - `pnpm typecheck:node` — passed. + - `pnpm typecheck:cli` — passed. + - `pnpm verify:bundled-skill-guides` and `git diff --check` — passed. +- Findings: + - The old subject-based failure convention was not merely confusing: it irreversibly completed a + failed Task. Requiring a tiny enum is a robust primitive, not workflow policy. + - Stop and abandon still need to share this terminal-transition fence before the broader + first-writer-wins checklist item can be marked complete. +- Next: + - Add the lightweight Run schema and explicit coordinator binding, then key new Task/Dispatch/ + Message state to that Run without changing agent placement policy. + +### 2026-07-22 — Lightweight Run foundation and inbox-only mail + +- Changes: + - Added schema v7 with lightweight Runs, stable explicit pane binding, consumer generations, and + an inspect-only legacy Run for all migrated pre-Run rows. + - Kept legacy automatic coordinator-loop storage in its existing `coordinator_runs` table. + - Added `run-create`, `run-use`, `run-current`, `run-list`, and `run-show` across CLI/RPC, with + explicit binding and no worktree or sole-candidate inference. + - Scoped new Task creation/list/update/dispatch operations to an explicit or currently bound Run; + Dispatches and decision gates inherit their Task's Run. + - Removed structured-mail prompt injection from send and ask. Mail now persists and wakes waiters + only; deliberate `dispatch --inject` and `terminal send` remain the input-writing paths. + - Renamed the local message renderer from `check --inject` to `check --format`, retaining only a + one-release RPC compatibility field. +- Files: + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orchestration/types.ts` + - `src/main/runtime/rpc/methods/orchestration-runs.ts` + - `src/main/runtime/rpc/methods/orchestration.ts` + - `src/main/runtime/orchestration/orchestration-error.ts` + - `src/main/runtime/rpc/errors.ts` + - `src/cli/specs/orchestration.ts` + - `src/cli/handlers/orchestration.ts` + - Related DB, RPC, CLI, and help tests; both skill sources and the bundled guide +- Verification: + - Focused DB/RPC/CLI command suite — 384 tests passed. + - `pnpm typecheck:node` and `pnpm typecheck:cli` — passed. + - `pnpm generate:bundled-skill-guides` and `pnpm verify:bundled-skill-guides` — passed. + - `git diff --check` — passed. +- Findings: + - Run identity is now real without changing scheduling or placement policy, but Messages and + questions still need stable logical recipients before mandatory Run association is complete. + - Consumer generation exists, but waiter cancellation and acknowledgment fencing belong to the + crash-safe Delivery implementation and remain intentionally unchecked. +- Next: + - Implement Run-owned logical mailboxes and stable `run:` / `dispatch:` routing, then + replace consume-on-read with one crash-safe outstanding Delivery per Run. + +### 2026-07-22 — Crash-safe Run inbox and durable questions + +- Changes: + - Added stable `run:` and `dispatch:` recipients; lifecycle sends from an active Dispatch + now default to its Run and no longer require agents to carry a coordinator terminal handle. + - Added schema v8 Deliveries: one FIFO batch of at most 50 rows, one outstanding batch per Run, + exact replay until whole-batch acknowledgment, and consumer-generation fencing. + - Made `check --ack --wait` perform ack, check, and waiter registration without an + intervening async gap; type filters are wake predicates and never split the FIFO batch. + - Added typed timeout, cancellation/connection-loss, second-waiter, stale-Delivery, and fenced- + consumer outcomes, plus persisted Delivery replay after an Orca database reopen. + - Added schema v9 question threads keyed by the original message ID, with active-Dispatch Run + defaulting, timeout-safe resume, current-consumer first-answer authority, idempotent replay, and + conflicting-answer rejection. + - Updated injected worker guidance and the orchestration skill so normal lifecycle and question + commands omit internal Run/server identity and coordinators explicitly process/ack each batch. +- Files: + - `src/main/runtime/orchestration/types.ts` + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orca-runtime.ts` + - `src/main/runtime/rpc/methods/orchestration.ts` + - `src/main/runtime/rpc/methods/orchestration-runs.ts` + - `src/main/runtime/orchestration/preamble.ts` + - CLI specs/handlers, RPC error mapping, skill sources, snapshots, and focused tests +- Verification: + - DB/RPC/runtime/CLI/preamble focused suite — 1,190 tests passed after the expected preamble + snapshot update. + - Delivery tests cover 50-row bounds, FIFO replay, idempotent ack, filter wake semantics, + consumer fencing, and reopen recovery. + - Question tests cover create/answer, same-answer replay, answer conflict, timeout persistence, + resume, unrelated wakes, and Dispatch closure storage behavior. +- Findings: + - A Run inbox needs only one durable Delivery row plus immutable message IDs; no dead-letter, + selective NACK, or second Event subsystem is necessary. + - Question answers belong in thread state rather than inbox-read state, so replying never + accidentally acknowledges the coordinator's whole Delivery. +- Next: + - Replace caller-supplied pane claims with a narrow Dispatch capability and add the durable + mutation ledger needed to recover unknown acceptance without replaying effects. + +### 2026-07-22 — Narrow Dispatch lifecycle capability + +- Changes: + - Minted a 256-bit per-Dispatch secret for injected workers while persisting only its SHA-256 + verifier. + - Bound lifecycle/question authority to the exact runtime-observed pane and PTY process + incarnation in addition to the Dispatch ID. + - Carried the secret in the authenticated RPC envelope across local socket, WebSocket, + shared remote-runtime, and SSH fallback transports rather than orchestration payload fields. + - Revoked the capability when worker completion/failure settles the Dispatch and stopped trusting + caller-supplied pane metadata in the SSH fallback. +- Files: + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orchestration/types.ts` + - `src/main/runtime/orchestration/preamble.ts` + - `src/main/runtime/rpc/core.ts` + - `src/main/runtime/rpc/dispatcher.ts` + - `src/main/runtime/rpc/methods/orchestration.ts` + - CLI and remote transport implementations plus focused tests +- Verification: + - Ten focused DB/RPC/runtime/CLI/SSH files — 1,238 tests passed. + - Capability cases cover missing token, wrong token, wrong pane, changed process incarnation, + success, and post-settlement revocation. + - `pnpm typecheck:node` and `pnpm typecheck:cli` — passed. +- Findings: + - Terminal handles and environment-provided pane strings are useful routing metadata but are not + lifecycle authority. + - Stop, abandon, and replacement must use the same revocation fence before that remaining + checklist item can be completed. +- Next: + - Add the durable mutation ledger and use it to recover unknown acceptance without repeating + external effects. + +### 2026-07-22 — Durable mutation receipts + +- Changes: + - Added schema v11 mutation receipts keyed by an authenticated-caller fingerprint and opaque + request ID, with canonical payload hashing and pending/completed state. + - Replayed completed results across retries and restart, joined concurrent identical mutations, + rejected changed input as `request_mismatch`, and surfaced orphaned pending work as + `operation_unknown`. + - Added request IDs to local socket, named-pipe, WebSocket, saved-environment, and SSH fallback + envelopes; successful receipts echo the ID while transport failures retain it as recovery data. + - Persisted blocking-question acceptance before waiting, so retry after a lost response returns + the original question instead of creating another. +- Files: + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orchestration/types.ts` + - `src/main/runtime/rpc/core.ts` + - `src/main/runtime/rpc/dispatcher.ts` + - Runtime-client and transport files, CLI orchestration handlers/specs, and focused tests +- Verification: + - Twelve focused DB/RPC/runtime/CLI/SSH files — 1,260 tests passed. + - `pnpm typecheck:node`, `pnpm typecheck:cli`, and `git diff --check` — passed. +- Findings: + - Generic control-plane mutations can safely discard a pending receipt when their handler returns + a known failure; future worker start/stop must instead return and preserve typed unknown outcomes + around external effects. + - The ledger infrastructure covers every existing V1 mutation; worker start/stop/abandon will be + added to the same policy when those commands exist. +- Next: + - Implement the same-server worker lifecycle and its first-writer-wins stop/abandon fence. + +### 2026-07-22 — Same-server worker lifecycle foundation + +- Changes: + - Added schema v12 composed-worker state and created the starting Dispatch plus Task transition + before terminal effects. + - Added synchronous `worker-start` for the current or an exact existing worktree, with fresh-agent + default, explicit-terminal reuse, TUI readiness, capability attachment, lifecycle injection, and + honest failed receipts with residual resources. + - Added Dispatch-routed `worker-show` and bounded `worker-read`. + - Added first-writer-wins `worker-stop` and `worker-abandon`; stop closes only the supervised agent + terminal, while abandon performs no process/filesystem action and retains residual receipts. + - Closed and woke pending question waits when a worker is settled, stopped, or abandoned. +- Files: + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orchestration/types.ts` + - `src/main/runtime/rpc/methods/orchestration-workers.ts` + - `src/main/runtime/rpc/methods/orchestration.ts` + - `src/main/runtime/orca-runtime.ts` + - CLI orchestration specs/handlers and focused DB/RPC/CLI tests +- Verification: + - Focused DB/RPC/CLI command suite — 412 tests passed. + - `pnpm typecheck:node` and `pnpm typecheck:cli` — passed. +- Findings: + - Keeping composed state in a narrow extension table preserves legacy Dispatch compatibility while + giving worker operations the richer start/stop states they need. + - New-worktree setup, stage journaling around worktree creation, and true unknown-start recovery + remain open; current/existing workers are the verified slice. +- Next: + - Add agent-first child/top-level creation with setup-run default and startup-policy receipts, then + extend the same Dispatch routing across connected Orca servers. + +### 2026-07-22 — Agent-first new-worktree workers and setup receipts + +- Changes: + - Added child and top-level agent-first worktree creation to `worker-start`, reusing the exact + startup agent terminal and listing setup/configured terminals as role-tagged effects. + - Made omitted setup resolve to `run`, preserved explicit run/skip/inherit and repository startup + policy, and returned `not_configured` when no setup hook exists. + - Kept `start-immediately` setup non-gating, persisted later setup success/failure, and emitted a + typed Run notice without changing a ready worker's lifecycle state. + - Narrowed setup receipts to callers that explicitly await terminal provisioning so ordinary + worktree creation does not report renderer-delegated setup as a false spawn failure. + - Added typed unknown-start recovery when worktree creation may have been accepted before a + connection failure. +- Files: + - `src/main/runtime/orca-runtime.ts` + - `src/main/runtime/rpc/methods/orchestration-workers.ts` + - `src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts` + - `src/main/runtime/rpc/methods/orchestration.test.ts` + - `src/shared/types.ts` +- Verification: + - New-worktree worker scenarios — 8 tests passed. + - Worker RPC plus runtime worktree suites — 911 tests passed. + - `pnpm typecheck:node` — passed. +- Findings: + - A setup status is only truthful after the supervised caller awaits the runtime's terminal + provisioning result; normal renderer-delegated creation should retain its existing launch + payload instead. + - The generic mutation receipt and starting Dispatch are still separate commits, so atomic start + acceptance and crash-boundary reconciliation remain open. +- Next: + - Make worker-start acceptance one transaction, then add restart reconciliation that never adopts + a same-looking pane or process. + +### 2026-07-22 — Atomic worker acceptance and conservative restart recovery + +- Changes: + - Moved the worker-start retry request insertion into the same SQLite transaction that creates the + starting Dispatch and moves its Task to dispatched. + - Added a process runtime epoch to composed Dispatches so interrupted starts/stops become explicit + unknown outcomes after restart instead of remaining indefinitely in transitional states. + - Made worker show/read/stop verify the persisted stable pane plus exact PTY process incarnation; + a same-looking replacement is reported as changed and is never read or closed. + - Restricted semantic retry to the Task's latest failed, stopped, or abandoned Dispatch. +- Files: + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orchestration/types.ts` + - `src/main/runtime/rpc/core.ts` + - `src/main/runtime/rpc/dispatcher.ts` + - `src/main/runtime/rpc/errors.ts` + - `src/main/runtime/rpc/methods/orchestration-workers.ts` + - Focused DB, mutation-ledger, new-worktree, and recovery tests +- Verification: + - Atomic acceptance and recovery suite — 231 tests passed. + - Follow-up DB/recovery/new-worktree suite — 93 tests passed. + - `pnpm typecheck:node` — passed. +- Findings: + - Runtime ID is useful only as a process epoch. It is not a durable server identity and must not be + used to route federated Dispatches. + - After restart, preserving uncertainty is safer than adopting a restored pane: the prior worker + may still exist, but only explicit stop/abandon/retry recovery may replace it. +- Next: + - Audit and close the remaining same-server acceptance rows, then implement saved-environment + placement and the narrow federated Dispatch attachment. + +### 2026-07-22 — Connected-server federation and crash-safe relay + +- Changes: + - Added worker-only `--on ` placement while keeping Run/Task authority on the + current server and routing later show/read/stop operations solely by Dispatch ID. + - Pinned remote Dispatches to the saved server public-key fingerprint and stored runtime identity + only as a replaceable process epoch; re-pairing returns `peer_changed` before effects. + - Added a narrow worker-server attachment with protocol version, capability verifier, exact + pane/process identity, effects, setup state, and bidirectional relay cursors—without copying the + Run DAG. + - Added durable worker-to-home lifecycle/question relay and home-to-worker reply relay with + contiguous sequence checks, source acknowledgment, quotas, heartbeat coalescing, and reserved + terminal-report capacity. + - Made home import commit the message, question/lifecycle transition, and source cursor in one + transaction before acknowledgment. + - Added federated show/read/stop, stop/completion ordering, timeout/resume after worker restart, + relay restart, exact-process checks, ack-loss replay, gap rejection, peer-change fencing, and + POSIX database/WAL/SHM permission coverage. Windows uses Orca's existing current-user-only + userData DACL boundary. + - Added an agent-facing cookbook for local fan-out, setup-default new worktrees, Mac/Windows + placement, completion/failure, ask/resume/reply, and conditional recovery. +- Files: + - `src/main/runtime/orchestration/environment-transport.ts` + - `src/main/runtime/orchestration/federation-sync.ts` + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/rpc/methods/orchestration-federation.ts` + - `src/main/runtime/rpc/methods/orchestration-workers.ts` + - Federation, permissions, CLI, transport, skill-guide, and protocol capability tests/sources +- Verification: + - Federation scenarios — 14 tests passed. + - Federation plus database-permission scenarios — 15 tests passed. + - Focused database/lifecycle/federation regression suite — 111 tests passed. + - `pnpm typecheck:node` and bundled skill-guide verification — passed. +- Findings: + - The stable saved-environment public-key fingerprint is server identity; a runtime UUID is only an + epoch and must never retarget a Dispatch after restart. + - The worker server needs only an authenticated attachment and relay outbox, not a replicated Run + database, scheduler, callback listener, or general ACL system. + - Setup remains explicit in receipts but non-gating by default; only repository + `wait-for-setup` policy delays agent launch. +- Next: + - Run the complete orchestration/CLI regression matrix, close any remaining checklist gaps, then + run repository validation and prepare the commit/PR. + +### 2026-07-22 — Final modularization and verification pass + +- Changes: + - Split federation, worker control/observation/topology, mutation execution, CLI specs, runtime + transport support, SSH error formatting, and database/CLI tests into domain-named modules so no + new max-lines bypass was needed. + - Regenerated the bundled skill manifests after the orchestration guide changed. + - Updated skill-guidance and lifecycle-rejection tests for the renamed legacy coordinator command, + setup-run default, agent-terminal fallback, and mandatory worker outcome. + - Kept unrelated daemon/UI/localization baseline failures out of the orchestration diff. +- Files: + - `src/main/runtime/rpc/methods/orchestration-federation-*.ts` + - `src/main/runtime/rpc/methods/orchestration-worker-*.ts` + - `src/main/runtime/orchestration/orchestration-*-db.test.ts` + - `src/main/runtime/rpc/orchestration-mutation-executor.ts` + - `src/main/runtime/rpc/runtime-feature-interaction.ts` + - `src/main/ipc/runtime-environment-shared-control-support.ts` + - `src/cli/specs/orchestration-worker-specs.ts` + - `src/cli/handlers/orchestration-run-cli.test.ts` + - `src/main/ssh/ssh-remote-cli-error-response.ts` +- Verification: + - Focused orchestration/CLI/SSH suite: 20 files, 565 tests passed. + - Runtime/subprocess/transport suite: 780 passed, 2 skipped. + - Updated skill-guidance/lifecycle-rejection tests: 12 passed. + - Full TypeScript check (Node, CLI, web), ordinary `oxlint`, max-lines ratchet, reliability gates, + bundled guide verification, manifest verification, and localization catalog verification passed. + - `pnpm test` could not start because the patched `node-pty` artifact does not load under local + Node 24.18.0. Direct full Vitest ran 33,058 passing tests; its 24 remaining failures and 3 worker + errors were native-PTY or unrelated timeout/baseline failures after the three stale + orchestration assertions were fixed and rerun. + - Full `pnpm lint` remains blocked only by unrelated existing switch-exhaustiveness and + localization-coverage failures outside this change. +- Findings: + - The implemented protocol has a clean authority split: the Run home owns orchestration truth; + connected worker servers own only exact resources, an authenticated Dispatch attachment, and + durable relay state. + - The remaining unchecked Phase 2/3 rows are real acceptance work, not reasons to add a scheduler, + UI, generalized capability matrix, provider-session layer, or automatic recovery. +- Next: + - Add the missing focused Phase 2 scenarios and run the Phase 3 matrix on real Mac/Windows and + WSL/SSH paths before marking those phases complete. + +### 2026-07-22 — Phase 2 setup and receipt acceptance complete + +- Changes: + - Made wait-for-setup receipts settle to `succeeded` only after gated agent readiness and fail at + `setup_start` or `setup_wait` before lifecycle/task input when setup is confirmed failed. + - Preserved `running` on a gated timeout, avoiding a false setup-failure claim. + - Added stage, role-tagged dispatch input, rich setup effect data, and exact terminal tab/leaf + coordinates to composed-worker receipts. + - Kept worker-start's option surface narrow: exact server via `--on`, exact repo via `--repo`, and + pass-through base/lineage/display/comment/setup choices. + - Updated CLI help, both skill sources, the generated guide/manifests, and the ignored HTML design. +- Files: + - `src/main/runtime/rpc/methods/orchestration-workers.ts` + - `src/main/runtime/rpc/methods/orchestration-worker-topology.ts` + - `src/main/runtime/rpc/methods/orchestration-federation.ts` + - `src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts` + - `src/main/runtime/rpc/methods/orchestration-federation-effects.ts` + - Focused worker, federation, and CLI tests plus help/skill/checklist/design sources +- Verification: + - Local/new-worktree/federation worker suites: 166 tests passed. + - CLI handler/help suite: 197 tests passed. + - Full Node/CLI/web TypeScript check passed. +- Findings: + - The existing worktree startup wrapper already enforces setup-before-agent ordering; the missing + work was truthful orchestration state and acceptance coverage, not a second setup runner. + - Phase 2 is complete. Remaining unchecked rows are the real connected-platform Phase 3 matrix + and cross-platform transport evidence. +- Next: + - Exercise federation disconnect/reorder/restart semantics, then run branch-head Mac/Windows + acceptance without replacing either production Orca runtime. + +### 2026-07-22 — Native Mac-to-Windows acceptance gaps + +- Changes: + - Built and launched branch-head Mac and Windows servers on isolated profiles and paired them over + the existing authenticated WebSocket transport. + - Used a temporary, exact Tailscale TCP proxy because Windows Firewall correctly blocked the new + test-binary port; production Orca and firewall policy were left unchanged. + - Added explicit dev-CLI provenance so custom profile paths still generate `orca-dev` worker + commands. + - Increased only the Windows ConPTY bracketed-paste render gap before Enter from 500 ms to 1.5 s. +- Files: + - `config/scripts/orca-dev.mjs` + - `src/cli/handlers/orchestration.ts` + - `src/shared/agent-prompt-injection.ts` + - Focused wrapper, CLI, and prompt-injection tests +- Verification: + - Native Windows discovery, exact worktree routing, pre-effect failure, retry, ready receipt, + worker-read, and remote worker-stop all returned truthful branch-head receipts. + - Focused dev-provenance and prompt-injection suite: 160 tests passed. +- Findings: + - A fresh Windows agent profile surfaces trust/login/update prompts as typed `agent_readiness` + failures with residual terminals, as designed. + - The first authenticated Windows worker proved that 500 ms could leave a long preamble in the + Codex input buffer, and that a custom dev profile could incorrectly call the production CLI. + - Phase 3 remains open until the fixes are rebuilt on Windows and the full completion/question/ + failure/restart matrix succeeds without a manual Enter or CLI substitution. +- Next: + - Rebuild both branch servers with these fixes and repeat the real Mac-home to Windows-worker flow. + +### 2026-07-22 — Headless Windows dev-worker CLI routing + +- Changes: + - Made every `orca-dev` entry path install profile-scoped `orca-dev` and `orca` terminal wrappers, + including Windows `.cmd` wrappers and headless `orca-dev serve`. + - Kept the wrapper generation shared with the Electron dev runner so interactive and headless dev + servers expose the same exact CLI and user-data profile to worker terminals. +- Files: + - `config/scripts/dev-cli-terminal-wrapper.mjs` + - `config/scripts/orca-dev.mjs` + - `config/scripts/run-electron-vite-dev.mjs` + - Focused cross-platform wrapper tests +- Verification: + - Wrapper, CLI provenance, dev-runner, preamble, and orchestration handler suites: 55 tests passed. + - Focused oxlint and formatting checks passed. +- Findings: + - The rebuilt prompt submitted automatically on Windows, but the worker then found no + profile-scoped `orca-dev` command because headless serve bypassed the Electron dev runner and the + runner itself had never written Windows wrappers into the PATH directory used by Orca terminals. + - This is a dev/acceptance launcher defect, not a new federation primitive or production routing + requirement. +- Next: + - Rebuild and restart the Windows branch server, then repeat the same Dispatch and require an + automatically relayed `worker_done` before checking any federation acceptance row. + +### 2026-07-22 — Mac-home to Windows-worker federation accepted + +- Changes: + - Fast-forwarded and restarted the isolated Windows branch server with the profile-scoped wrapper + fix while preserving its authenticated server key and saved-environment binding. + - Exercised separate success, intentional failure, and blocking question/reply Dispatches from the + isolated Mac Run home to native Windows Codex workers. +- Files: + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - Success `ctx_f982ddb1bdf9` submitted without manual input, relayed `worker_done`, and atomically + settled its Task/Dispatch as completed/succeeded. + - Failure `ctx_5f28665cf04a` relayed `outcome=failed` and atomically settled its Task/Dispatch as + failed without treating failure prose as success. + - Question `ctx_4aec24c47e30` relayed a typed question to the Mac Delivery, carried the `blue` reply + back to the blocked Windows ask, then relayed a successful terminal report. + - Routed bounded read and exact-agent stop were also exercised against Windows Dispatch receipts; + stop closed only the accepted agent terminal. +- Findings: + - Windows ConPTY prompt submission, profile-specific CLI selection, authenticated lifecycle + acceptance, contiguous bidirectional relay, whole-batch acknowledgment, and terminal-state + reconciliation now pass together in the real Mac-to-Windows path. + - This completes only the named Mac-home to Windows-worker row; reverse direction, restart with an + active Dispatch, disconnect/unknown-outcome, WSL/SSH/relay-host, and transport coverage remain + open. +- Next: + - Pair the isolated Mac server into the Windows test profile and run the same acceptance flow with + Windows as the Run home. + +### 2026-07-22 — Windows-home to Mac-worker federation accepted + +- Changes: + - Added a reciprocal saved Mac environment to the isolated Windows profile without exposing its + pairing credential in terminal history. + - Exercised separate success, intentional failure, blocking question/reply, bounded read, and exact + stop Dispatches with Windows as Run home and native macOS as worker server. + - Made worker observations report `exited` for the exact disconnected terminal instead of + misleadingly projecting `running`; stop now refuses to close an exact-but-exited process again. +- Files: + - `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` + - `src/main/runtime/rpc/methods/orchestration-federation-control.ts` + - `src/main/runtime/rpc/methods/orchestration-worker-stop.ts` + - Focused local/federated observation and stop tests +- Verification: + - Success `ctx_5188e1f8417e` relayed from macOS and settled at the Windows Run home. + - Failure `ctx_a3418dc84ed6` relayed `outcome=failed` and settled failed at the Windows home. + - Question `ctx_a2521b88b6c9` carried `square` from Windows to the blocked macOS ask, followed by a + successful terminal report. + - Stop `ctx_bec120349d3f` first proved routed read against the exact Mac worker, then closed only that + terminal and settled stopped/failed; the observation regression suites pass 36 tests. +- Findings: + - One reciprocal pairing is sufficient for a Windows-owned Run to route Mac worker control while + preserving a single Run database on Windows; no replicated scheduler or failover layer is needed. + - Stable process identity and live process status are separate facts. A disconnected terminal can + still be the exact historical worker, but it must not be labeled running or closed again. +- Next: + - Validate active-Dispatch restart/disconnect and exact-retry behavior, then exercise WSL/SSH/relay + execution-host propagation without broadening the federation protocol. + +### 2026-07-22 — Federated restart and pre-acceptance disconnect accepted + +- Changes: + - Restarted the Windows Run home alone, the macOS worker server alone, and both servers while each + had an active federated Dispatch. + - Stopped the macOS worker server before a Windows-home start request could be accepted. +- Files: + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - Home-only restart preserved `ctx_f0693ff30c27`; `worker-show` found the same exact running macOS + worker under the unchanged worker epoch, and routed stop succeeded. + - Worker-only restart preserved `ctx_3bbb0142f9aa` at its Run home while the new macOS epoch + truthfully reported the missing terminal as non-exact; routed stop returned `stop_unknown` + without adopting or closing another process. + - Restarting both sides preserved `ctx_d46f68fa1400` and its attachment; inspection used the new + worker epoch, reported `missing` with `exactWorker=false`, and stop again returned + `stop_unknown` safely. + - With macOS already unreachable, retry request `disconnect-before-send-01` created neither a + Dispatch nor an attachment for `task_04fd0dc61065`; the Task remained ready. +- Findings: + - Durable home state and worker identity fencing survive independent epochs without requiring Run + replication or authority failover. + - A failed connection before remote acceptance is a clean no-effect result; it must not be + promoted to an ambiguous outcome or consume the Task. +- Next: + - Disconnect the worker route after possible acceptance, then follow the returned exact recovery + command and prove that retry deduplicates to one remote effect. + +### 2026-07-22 — Post-acceptance disconnect deduplicated + +- Changes: + - Cut the Windows Tailscale proxy while a Mac-home `worker-start` was provisioning remotely, then + restored the same route and replayed the exact application request ID. + - Allowed an explicit `worker-stop` to fence `start_unknown` locally and on the worker server; + exact pane/process observation still decides whether a terminal may actually be closed. +- Files: + - `src/main/runtime/orchestration/db.ts` + - `src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts` + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - The lost response left `task_bb86c87bdc60` attached to one pending Dispatch, + `ctx_6580d07d9006`, rather than creating a replacement. + - Replaying request `disconnect-after-start-01` returned the same Dispatch with + `state=outcome_unknown` and `mutation.replayed=true`. + - Remote inspection found one `disconnect-after-acceptance-01` worktree, one exact agent + terminal, and one expected setup terminal; no duplicate topology was created. + - Worker Dispatch DB, federated control, and recovery suites passed 29 tests; node typecheck and + focused oxlint passed. Branch-head CLI and Electron builds succeeded on macOS and Windows. + - Relay tests reject an out-of-order sequence without advancing the cursor, later accept the + missing and retried frames contiguously, treat the repeated frame as a duplicate, and preserve + exactly two messages. Lost-ack retry and mailbox delivery suites passed with it (24 tests). +- Findings: + - The acceptance run exposed one narrow recovery inconsistency: `worker-show` could prove an exact + worker existed while `worker-stop` rejected the durable `start_unknown` state. Explicit stop now + enters the same fenced stopping path from `ready` or `start_unknown`; unattached, missing, or + identity-changed workers still become `stop_unknown` without a process action. + - No scheduler, automatic retry, adoption, cleanup, or general distributed-operation framework is + needed for this recovery path. +- Next: + - Exercise duplicate/reordered relay convergence and the native/WSL/SSH transport matrix, then + synchronize the HTML proposal with the implemented contract. + +### 2026-07-22 — Cross-platform capability transport verified + +- Changes: + - Added an SSH compatibility-bridge test that carries the opaque Dispatch capability in the RPC + envelope and settles only the matching pane/process Dispatch. + - Added a composed worker-start test that binds the host-resolved `orca-ide` command and the + Dispatch capability into one WSL worker preamble. + - Updated the built-CLI reset fixture to recreate its required coordinator Run after a task reset. + - Updated the HTML recovery contract so explicit stop from `start_unknown` remains fenced and + process-identity checked. +- Files: + - `src/main/ssh/ssh-remote-orca-cli.test.ts` + - `src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts` + - `src/main/runtime/orchestration-cli-subprocess.test.ts` + - `docs/orchestration-primitives.html` (ignored design source) + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - Native macOS Unix-socket worker control, native Windows named-pipe lifecycle reporting, and + bidirectional authenticated WebSocket federation passed in the real Mac/Windows matrix. + - On the real Windows host, the PowerShell/SSH launcher, SSH command allowlist, host passthrough, + WSL/native command selection, and multiline Windows quoting suites passed 31 tests; 13 + Unix-socket-only runtime-client tests correctly skipped on Windows. + - Platform-neutral SSH capability, WSL command selection, preamble, worker-start, and CLI envelope + suites passed 94 focused tests on macOS. + - The rebuilt CLI plus every orchestration, composed-worker, federation, and SSH regression file + passed together: 25 files and 456 tests. Full Node/CLI/web typecheck, focused oxlint/format, + generated-skill verification, and `git diff --check` passed. +- Findings: + - The available Windows acceptance host has no WSL distribution installed and prompts to install + the feature. Acceptance therefore uses explicit WSL host/path tests rather than mutating the + machine. SSH is likewise exercised at the bridge and lifecycle boundary rather than requiring a + new external host. + - Native and relay-backed behavior is real end-to-end evidence; WSL and SSH evidence is bounded to + the host-selection, prompt, envelope, quoting, allowlist, and lifecycle contracts Orca owns. +- Next: + - Run the remaining full validation and Linux CI, then finish the ignored HTML synchronization and + PR evidence without adding new orchestration concepts. + +### 2026-07-22 — Phase 3 and cross-platform gates closed + +- Changes: + - Marked connected-server federation and the native cross-platform quality gate complete after + branch-head Linux CI joined the real macOS/Windows acceptance evidence. +- Files: + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - PR #9925 completed with 17 successful checks and no failures: full Linux verify, Ubuntu and + Windows native smoke, packaged Windows crash survival, and macOS/Ubuntu/Windows skill round trips. + - Linux verify passed lint, generated-skill checks, max-lines enforcement, typecheck, repository- + wide tests, unpacked-app build, and packaged CLI smoke. +- Findings: + - Every implementation phase is now either complete or explicitly deferred with its prerequisite; + no additional orchestration subsystem is required for V1. +- Next: + - Review and merge PR #9925; keep Phase 4 deferred until exact provider-session association exists. + +### 2026-07-22 — Final agent-contract audit + +- Changes: + - Synchronized the ignored HTML examples with the shipped mutation, Delivery, worker-start, and + terminal-read result shapes; removed speculative Phase 4 output fields from the V1 path. + - Made semantic retry explicitly repeat placement and agent/terminal choices, while transport + recovery reuses only the exact `mutation.requestId` after a lost response. + - Corrected worker state/error terminology, the cross-platform structured completion recipe, and + the HTML implementation-status footer. + - Updated the installed/versioned orchestration guidance to prefer `worker-start`, use + `question` mail, and reserve low-level `dispatch --inject` for custom topology. + - Fixed `worker-read --cursor 0`; the runtime supported the initial retained-output cursor but the + orchestration CLI incorrectly required a positive value. +- Files: + - `src/cli/handlers/orchestration.ts` + - `src/cli/handlers/orchestration-worker-cli.test.ts` + - `src/cli/specs/orchestration-worker-specs.ts` + - `skill-guides/orchestration.md` + - `skills/orchestration/SKILL.md` + - generated bundled skill guide and skill-bundle manifests + - `docs/orchestration-primitives.html` (ignored design source) + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - Focused worker CLI suite passed 3 tests, including cursor zero. + - Final orchestration runtime/CLI/renderer/skill regression set passed 24 files and 450 tests. + - Node, CLI, and web typechecks, bundled-guide and skill-manifest verification, focused + formatting, and `git diff --check` passed. + - A repository-wide run reached 34,190 passing tests and exposed the one intentionally changed + skill assertion; after updating that assertion, the focused configured rerun above passed. The + broad run also exhausted several Vitest fork-start deadlines under full local concurrency, while + the previously green PR checks remain the authoritative clean full-suite baseline. +- Findings: + - The implementation already returned durable request IDs as `mutation.requestId`; the remaining + problem was stale naming and invented provenance fields in the ignored design example. + - Stored start options are recovery evidence, not implicit replacement policy. Requiring explicit + replacement placement keeps agents in control and avoids recreating a possibly-existing remote + worktree by accident. + - No new scheduler, retry engine, output adapter, projection layer, or federation subsystem is + needed. Phase 4 remains deferred. +- Next: + - Push the audit corrections and confirm PR #9925 is green at the new head. + +### 2026-07-22 — CodeRabbit and internal review until clean + +- Changes: + - Validated every CodeRabbit finding; fixed relay type validation, stale runtime-owned terminal + identity, setup receipt classification, Windows batch percent escaping, worker reconciliation, + root-help discovery, canonical question schema, and deterministic waiter timing. + - Coalesced overlapping per-Dispatch federation polls and added one warning per outage window. + - Centralized the orchestration RPC envelope type without adding a new runtime abstraction. + - Made reset scopes atomic and cleared matching worker/federation state while preserving relay + cursors for message-only resets and the mutation ledger needed for lost-response deduplication. +- Files: + - orchestration runtime, federation, worker-control, database, CLI help/client, wrapper, skill, and + focused regression tests listed in the current working diff. +- Verification: + - Focused runtime, federation, database, CLI, SSH, wrapper, and skill suites passed 1,103 tests. + - The repository-wide configured suite passed 34,253 tests with 58 intentional skips. + - Node, CLI, and web typechecks passed; focused oxlint, formatting, generated-skill checks, and + `git diff --check` passed. +- Findings: + - The type-only pairing import is valid TypeScript and remains type-only; Dispatch capability + flags stay intentionally hidden because the authenticated worker preamble supplies them. + - Persistent envelope-bearing WebSocket reuse remains a measured-later optimization; the V1 + single-flight relay removes overlapping connection churn without growing transport scope. + - The final re-review found no remaining in-scope correctness, ergonomics, elegance, or + performance defect after fixing reset scope and relay-cursor preservation in round 2. + - Full lint reaches unrelated existing failures in the unchanged skill-freshness switch and + localization catalog; changed-file lint and every in-scope quality gate pass. +- Next: + - Resolve CodeRabbit threads and confirm the PR checks at the final head. + +### 2026-07-24 — Post-rebase physical federation dogfood + +- Changes: + - Rebased the branch onto current main and launched isolated branch-head desktop runtimes on the + Mac Run home and the physical Windows worker server. + - Started a real Windows Codex worker in a new top-level worktree with explicit setup-run. + - Replaced the orchestration process fence's renderer generation with the controller-issued PTY + incarnation when available, retaining the prior value only for legacy providers. + - Added a runtime regression covering a visible terminal surface detaching and reattaching around + the same process, followed by a replacement incarnation. +- Files: + - `src/main/runtime/orca-runtime.ts` + - `src/main/runtime/orca-runtime.test.ts` + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - The remote start returned ready in about nine seconds with setup `running`, + `startupPolicy=start-immediately`, one agent terminal, one setup terminal, and accepted task + input. + - The original branch head then reproduced immediate `identity_changed`: routed read failed and + the exact injected capability could not send a heartbeat or question. + - The new focused process-identity regression passed, the 18-test federation suite passed, and + Node typecheck passed. +- Findings: + - A renderer pane generation is presentation state, not process identity. A healthy PTY can move + between a runtime-owned background surface and the renderer without losing Dispatch authority. + - The PTY controller incarnation is already available on native Windows/macOS and SSH/relay paths; + using it fixes the race without relaxing replacement-process fencing or adding a new identity + subsystem. +- Next: + - Rebuild both branch runtimes, repeat the full physical Mac-to-Windows lifecycle, then run the + remaining local error/recovery matrix before closing the revalidation rows. + +### 2026-07-24 — Post-fix physical federation revalidation + +- Changes: + - Rebuilt and restarted the physical Windows branch runtime from `b09c635ec`, then repeated the + Mac Run-home to Windows-worker flow on a fresh top-level worktree. + - Abandoned the controlled stale-build attempt, linked the replacement with `--retry-of`, and + exercised exact start-request replay plus exact remote worker stop. + - Exercised missing-agent and invalid-remote-repo rejection without adding recovery automation. +- Verification: + - The replacement returned ready while setup was running under `start-immediately`; + `worker-show` reported the exact running process, bounded `worker-read` succeeded, and the + worker's heartbeat reached the Mac Run home. + - An inferred-Run blocking question carried the `blue` reply back to Windows, and the authenticated + success report atomically settled the Task and Dispatch. + - A transient connection close before acceptance left the second Task ready with no Dispatch. + Retrying its exact request ID started one worker, and repeating that request returned the same + Dispatch with `replayed=true` and no duplicate worktree or terminal. + - `worker-stop` killed only the exact agent PTY. The setup terminal remained present, the Dispatch + became stopped/failed, and the Task became blocked for explicit recovery. + - An unconfigured agent failed locally. A missing remote repo produced a typed failed Dispatch + with `effects=[]` and no residual resources. +- Findings: + - The first repeated failure was a stale Windows build artifact, not a failed fix: the source + checkout was at `b09c635ec` while `out/main/index.js` still had the old generation fence. + Relaunching the branch dev process produced a new runtime epoch and the fixed behavior. + - Omitted setup correctly resolved to Orca's `run` default. Its independent Windows install later + failed in `windows-native-registry`, but that did not delay task delivery and remained isolated + from exact agent stop. + - From an unmanaged shell, a coordinator mailbox check must name `--terminal`; a CLI running + inside the bound coordinator terminal continues to infer that identity normally. +- Next: + - Resolve only concrete findings from the independent ergonomics/federation re-review, run the + final local verification set, and reconcile PR review threads and CI. + +### 2026-07-24 — Post-dogfood recovery and ergonomics hardening + +- Changes: + - Made lost remote stop responses reconcilable and prevented stale Dispatch abandonment from + blocking an active replacement. + - Persisted setup completion as evidence only, preserving settled lifecycle state locally and + relaying the same outcome from a connected worker server. + - Rejected misleading explicit targets from federated workers and consumed `ask` answers exactly + once while retaining their durable thread record. + - Stored the accepted Dispatch in pending worker-start receipts so a post-restart retry returns the + exact inspection command. + - Rejected new-worktree placement for folder projects before effects. + - Restored the generated skill-history ledgers that the branch had accidentally truncated. +- Verification: + - The full orchestration DB/RPC/CLI/SSH regression selection passed 516 tests; its focused + recovery, setup, messaging, and mutation slice passed 239 tests. + - The earlier physical Mac-home to Windows-worker lifecycle covered ready/read/heartbeat, + ask/reply, completion, exact request replay, and exact stop. +- Findings: + - These were narrow truthfulness and recovery gaps; none required a scheduler, automatic retry, + access-control framework, replicated Run database, UI, or provider-session abstraction. + - The release-contract test failure is already present on `main`; it is separate from this + orchestration change. The skill round-trip failures were branch-caused and are fixed by + restoring their committed history. +- Next: + - Run the complete changed-file quality gates, rebuild the physical Windows dev runtime with this + final patch, repeat the setup-status slice, then push and recheck PR CI/review state. + +### 2026-07-24 — Physical receipt follow-up + +- Changes: + - Made local and connected-server ready transitions persist the accepted `dispatch_input` effect + atomically, so later setup evidence cannot replace it with an older effect snapshot. + - Carried the already-resolved setup source through the internal federation attach request, keeping + omitted setup labeled `orchestration_default` and explicit setup labeled `explicit_request`. +- Verification: + - The focused new-worktree, federation, and setup-evidence slice passed 37 tests. + - The broader orchestration DB/RPC/CLI/SSH execution-host selection passed 630 tests. + - Node and CLI typechecks, changed-file lint/format, and `git diff --check` passed. +- Findings: + - Both defects were receipt-provenance bugs found by the physical Mac-home to Windows-worker run; + neither changes worker placement, setup timing, lifecycle authority, or agent-facing commands. +- Next: + - Rebuild both dev runtimes from this patch and repeat the physical setup-status slice before final + PR reconciliation. + +### 2026-07-24 — Truthful setup command completion + +- Changes: + - Wrapped only orchestration-created non-gating setup commands with a private per-invocation + completion signal that preserves the command exit code. + - Added one runtime observer that subscribes to raw PTY output, replays the bounded recent-output + buffer, scans across chunk boundaries, and treats terminal exit as a fallback. + - Updated local and connected-server setup evidence monitors to observe command completion while + leaving the interactive setup terminal open. + - Propagated the exact setup terminal handle through worktree receipts and hardened native Windows + launch with an encoded PowerShell command plus an environment-carried runner path. +- Files: + - `src/main/runtime/orchestration/setup-completion-signal.ts` + - `src/main/runtime/orca-runtime.ts` + - local and federation setup monitors and focused tests + - `ORCHESTRATION_IMPLEMENTATION_CHECKLIST.md` +- Verification: + - The completion helper, exact-effect, local worker, federation, and setup-evidence suites passed + 43 tests. + - Focused runtime tests proved live completion, replay-before-observer recovery, and opt-in setup + wrapping while the shell remains running. + - The complete runtime service suite passed 884 tests. + - The broader orchestration/CLI/SSH/execution-host selection passed 529 tests. + - Node, CLI, and web typechecks, the CLI/Electron build, focused lint/format, max-lines ratchet, + and `git diff --check` passed. + - The independent no-scope-creep recheck found no remaining correctness blocker. +- Findings: + - Terminal exit is not setup-command completion because Orca intentionally runs setup in an + interactive terminal that returns to a shell prompt. + - Display titles are not terminal identity, and a disconnected terminal is not proof that its + setup command failed. + - The correction is runtime evidence only: it adds no public flag, setup job, scheduler, retry + policy, process heuristic, or automatic tab closure. +- Next: + - Rebuild both dev runtimes and repeat the physical Mac Run-home to Windows-worker setup-status + slice, including a failing setup command that returns to a PowerShell prompt. + +### 2026-07-24 — Physical Windows setup-completion proof + +- Changes: + - Rebuilt and restarted the Mac Run-home and physical Windows worker runtimes from `4aba390af`. + - Started a fresh Windows Codex worker from the Mac with omitted `--setup`, a new top-level + worktree, and the exact Windows repo selector. +- Verification: + - Mac runtime epoch `4a2a1cba-fd8b-41c7-b50b-caeac7415d9c` and Windows runtime epoch + `04cecad3-65a9-49a5-90ff-cdf04b09050f` both became ready after restart. + - Run `run_0f6b471005af`, Task `task_11e981c692b5`, and Dispatch `ctx_f009a65c6d9d` returned + ready with setup `running`, source `orchestration_default`, policy `start-immediately`, the exact + setup terminal `term_bdcb2a0b-89c5-429c-8ae6-0fdc2457db15`, and accepted dispatch input. + - The real Windows setup command later exited 1 in `windows-native-registry`; `worker-show` + changed setup to `failed` while preserving the succeeded worker, settled Dispatch, accepted + input effect, and exact setup-terminal effect. + - The setup terminal remained running and accepted a follow-up PowerShell command after failure. + The Run mailbox contained exactly one high-priority setup-failed notice for the Dispatch. +- Findings: + - The private per-invocation marker carried exit code 1 in raw setup-terminal output and did not + appear in orchestration receipts or lifecycle messages. + - Windows terminal reads still flatten PowerShell line-editor redraws into noisy repeated input + text. The command executed once and orchestration state remained correct, so this pre-existing + rendering artifact stays outside this PR. +- Next: + - Re-run the final local quality gates, push this evidence-only checklist update, and reconcile PR + CI and review state. + +### 2026-07-24 — Structured worker-output implementation + +- Changes: + - Extended `worker-read` with `auto|transcript|terminal` source selection while preserving one + Dispatch-only agent command. + - Added exact pane/process/session selection from existing hook evidence, bounded Codex/Claude + transcript reading, path-free source identities, and opaque source-pinned cursors. + - Added a worker-local federated output RPC; mixed-version servers fall back through the existing + terminal-read method and still receive an opaque Run-home cursor. + - Added readable non-JSON transcript rendering, CLI help, skill guidance, typed errors, and + malformed/oversized/clipping warnings. +- Files: + - `src/shared/orchestration-worker-output.ts` + - `src/main/runtime/orchestration/worker-output-cursor.ts` + - `src/main/runtime/orchestration/worker-provider-session.ts` + - `src/main/runtime/orchestration/worker-transcript-payload.ts` + - `src/main/runtime/orchestration/worker-transcript-read.ts` + - `src/main/runtime/rpc/methods/orchestration-worker-output.ts` + - Worker control/federation, runtime status lookup, CLI, skill, design, and focused tests +- Verification: + - Node and CLI typechecks passed. + - Nineteen native-chat/structured-output suites passed 142 tests. + - Sixteen orchestration CLI/RPC/federation suites passed 289 tests. + - Five CLI registry/help/runtime-error suites passed 213 tests. + - Mixed-version fallback continuation was additionally verified with an opaque cursor. +- Findings: + - The prior Phase 4 deferral was factually wrong: hook snapshots already bind provider sessions to + exact panes. Reusing that evidence avoids directory/title/logo guessing and avoids a second + status subsystem. + - Method probing is enough for mixed-version compatibility; a generalized provider capability + matrix is unnecessary. +- Next: + - Run the remaining full lint/test gates and the physical local plus Mac/Windows dogfood matrix + before marking Phase 4 complete. + +### 2026-07-24 — Structured-output local and mixed-version dogfood + +- Changes: + - Ran two simultaneous same-worktree Codex Dispatches with unique markers and verified exact + transcript isolation plus opaque continuation. + - Made transcript-position fallback IDs opaque after the physical response exposed the local + Codex JSONL path. + - Redacted pane-bound Dispatch capability tokens from structured prose, tool input, tool output, + metadata, and image URLs after continuation exposed the lifecycle send command. + - Corrected `worker-read --help` so `--cursor` is described as opaque rather than numeric. + - Made forward paging advance safely across a transcript record larger than the bounded scan + window, while continuing to discard its unfinished fragments. + - Extended Dispatch-capability redaction to tool-input object keys as well as values. +- Verification: + - Both simultaneous local reads selected different exact Codex source identities and contained + only their own marker. + - Continuation returned newly appended tool/assistant messages, `limited=true`, and the expected + completion marker. + - A fresh local response contained stable `worker-message-*` IDs, no `.codex/sessions` path, no + `dcap_` token, and explicit privacy/redaction warnings. + - Starting a new Codex chat in the same pane caused the old cursor to return `source_changed`; a + fresh read selected only the new chat marker. + - Restarting the Run-home runtime preserved settled state and rejected a read when the exact + worker process was no longer present. + - Mac Run home -> older Windows worker returned `source=terminal`, + `fallbackReason=remote_capability_unavailable`, an opaque cursor that continued successfully, + and `transcript_required` when structured output was explicitly required. +- Findings: + - Synthetic path-leak tests need fallback-ID records, not only provider records with explicit IDs. + - Structured output must treat lifecycle capability text as secret even though the capability is + also pane-bound; redaction is a narrow output boundary, not a generalized secret scanner. + - Additive RPC probing works against the physical older Windows server without a capability + matrix or server upgrade gate. + - A bounded scan must still guarantee cursor progress; otherwise one pathological provider record + can trap an agent in a valid-looking continuation loop. +- Next: + - Commit/push the tested implementation, update the physical Windows dev runtime, then run exact + Mac-to-Windows and Windows-to-Mac structured reads plus disconnect/reconnect. + +### 2026-07-24 — Reverse dogfood found missing coordinator control mail + +- Finding: + - A Windows Run home successfully started and read an exact Mac Codex worker, and worker-to-home + status relayed correctly. However, coordinator mail addressed to that remote worker remained + queued at the Run home because only question replies used the home-to-worker relay. + - The injected worker's local `check --wait` also looked only for a same-server Dispatch, so even + an imported generic message could not wake it. +- Changes: + - Route stable `dispatch:` coordinator guidance through the existing per-Dispatch durable + relay; terminal-handle targeting remains a legacy/local path. + - Import control mail idempotently on the worker server, tolerate a replay after a lost import + acknowledgment, and wake only the exact attached worker process. + - Return a direction-aware relay receipt and teach CLI help, the versioned skill, and the + cross-server cookbook to use the Dispatch ID for follow-ups. +- Verification: + - Focused Node/CLI typechecks and 184 orchestration/federation/CLI tests passed before physical + revalidation. +- Next: + - Regenerate the bundled skill guide, re-review the narrow change, then repeat Windows-home to + Mac-worker follow-up, completion, exact transcript continuation, and disconnect/reconnect. + +### 2026-07-24 — Federated control-mail race hardening + +- Changes: + - Fence already-imported relay sequences before parsing or applying message side effects. + - Require the remote attachment to remain ready before accepting each new coordinator message. + - Recheck the Run-home worker state after importing worker lifecycle mail and do not push queued + guidance after the worker settles. + - Wake filtered worker waiters with the imported message's real type instead of always using + `status`. + - Negotiate a narrow control-mail capability and reject the send before queueing when an older + worker server supports base federation but not the new relay kind. +- Verification: + - Regression tests prove a replayed sequence with a different message ID creates no duplicate. + - A waiter registered before `worker_done` receives no stale control mail after completion, and a + direct late import is rejected as inactive. + - An imported escalation wakes an escalation-filtered waiter while a status-filtered waiter times + out normally. + - A new Run home connected to a prior worker build can still start the worker, but control mail + returns `capability_unsupported` and leaves no undeliverable relay row. + - The focused federation suites passed 24 tests; the broader orchestration/CLI selection passed + 475 tests. + - Full typecheck, lint, bundled-skill verification, CLI build, Electron/Vite build, + `git diff --check`, and the design-document reference-name audit passed. +- Findings: + - Relay ordering and worker settlement are control-plane guardrails, not agent policy: the + coordinator still chooses what to send and when. + - Terminal worker state is authoritative for delivery; queued guidance is retained at the Run + home but never injected into a completed worker. +- Next: + - Complete the final read-only review, then repeat the physical Windows-home to Mac-worker + follow-up, completion, exact transcript continuation, and disconnect/reconnect proof. + +### 2026-07-24 — Physical control-mail acceptance and dogfood fixes + +- Changes: + - Restarted the physical Windows runtime from branch head and confirmed both federation + capabilities before creating a fresh Windows-home Run. + - Fixed `check --ack --peek` so the exact Delivery is acknowledged before the + read-only history projection; the previous early return silently ignored `--ack`. + - Preserved the existing structured remote transport codes through the Run-home RPC boundary + instead of collapsing disconnect, timeout, and malformed-response failures to `runtime_error`. +- Verification: + - Run `run_074503e3edc6`, Task `task_8a07840e7aad`, and Dispatch `ctx_d6bac1ee6409` started a fresh + Codex worker in the existing Mac worktree with setup `not_applicable`. + - The worker relayed `ORCA_MAC_CONTROL_INITIAL_7C31`, blocked on its Dispatch inbox, received + coordinator guidance addressed to the stable Dispatch as `ORCA_MAC_CONTROL_FOLLOWUP_A842`, and + returned one authenticated successful `worker_done` containing both markers. + - The Windows home settled the Task and Dispatch once. `worker-read --source auto` returned + `source=transcript`, `provider=codex`, an opaque cursor, both markers, and no capability token or + transcript path. + - Removing the Mac listener left the Windows Task completed. Reconnecting with the wrong leftover + profile was rejected as unauthorized; reconnecting with the original profile preserved the + settled Dispatch and correctly returned `worker_identity_changed` for transcript reads after the + exact worker process was gone. + - The focused RPC suites passed 163 tests; formatting, diff checks, full typecheck, CLI build, and + desktop/web builds passed. + - After the Windows generated main bundle was verified at `8dd0d1b16`, Delivery + `delivery_888f972841d6` was acknowledged by `check --ack ... --peek`; the response echoed the + exact acknowledged ID and returned zero unread rows. + - With that build running, removing the Mac listener returned + `remote_runtime_unavailable` while the Task stayed completed. Reconnect preserved the succeeded + Dispatch, produced no duplicate Run mail, and returned `worker_identity_changed` rather than + attributing the old transcript to a replacement process. +- Findings: + - Delivery acknowledgment must compose with inspection modes explicitly; a successful command may + not silently ignore the acknowledgment effect. + - Remote transport already had narrow error codes. Preserving them is enough; no federation error + hierarchy or retry engine is needed. + - Saved peer identity fencing prevented accidental adoption of a server started from a different + profile. Exact process fencing also prevented stale transcript attribution after restart. + - On the Windows dogfood shell, the `pnpm` wrapper returned before its spawned Vite build + completed. Verifying the generated bundle before restart exposed the race; invoking the Node + build script directly produced the expected branch-head bundle. +- Next: + - Run the final quality gates and review, push this evidence update, inspect PR CI, and remove only + the temporary dogfood profile and listener after verification is complete. + +### 2026-07-24 — Current-main rebase integration + +- Changes: + - Rebased the full implementation onto current `origin/main`. + - Combined main's bounded one-shot remote-request admission with the orchestration authentication + envelope in the same pre-serialized encrypted request. + - Added a direct WebSocket regression proving the admitted request retains the orchestration + capability and mutation ID. +- Verification: + - The repository-configured orchestration, federation, remote-client, and skill selection passed + 35 files and 465 tests. + - Full Node/CLI/web typecheck, lint/reliability/manifest/localization gates, focused formatting, + generated-skill verification, and `git diff --check` passed after the rebase. + - Relay, CLI, Electron/Vite, and web production builds passed. The local CLI installer reported + only the expected non-fatal lack of permission to replace `/usr/local/bin/orca-dev`. +- Findings: + - The request must be serialized with its authentication envelope before it reserves bounded + admission; rebuilding the frame after authentication would bypass the retained-byte contract. +- Next: + - Push the rebased branch, inspect branch-head CI, then remove only the exact temporary dogfood + resources. + +### 2026-07-24 — Structured-output proposal synchronization + +- Changes: + - Updated the newer HTML proposal from its obsolete terminal-only Phase 4 deferral to the shipped + `auto|transcript|terminal` contract. + - Documented exact pane/process/session selection, opaque source-pinned cursors, labeled fallback, + mixed-version behavior, and the implemented Phase 4 status. +- Verification: + - Confirmed the proposal contains no named references to other orchestration products. +- Findings: + - Implementation status and optional remaining physical acceptance are separate: the narrow output + primitive is complete, while symmetric cross-machine dogfood remains visible in this ledger. +- Next: + - Run document/skill checks, push the synchronization fix, resolve the review thread, and continue + branch-head CI monitoring. + +### 2026-07-25 — Physical Grok and OpenCode provider dogfood + +- Changes: + - Reused Native Chat's existing Grok session resolver and transcript decoder in `worker-read`. + - Kept OpenCode on the generic terminal fallback because Native Chat has no OpenCode transcript + decoder. + - Applied Dispatch-capability redaction to terminal fallback lines as well as structured + transcript blocks. + - Updated the agent-facing skill and proposal to name the current structured provider set. +- Verification: + - An isolated branch-head server started fresh same-worktree Grok and OpenCode workers through + `worker-start`; both accepted their injected tasks and returned authenticated successful + `worker_done` reports. + - Grok returned `source=transcript`, `provider=grok`, the exact marker, opaque message IDs and + cursor, and no capability token or transcript path. + - OpenCode returned `source=terminal`, `fallbackReason=provider_unsupported`, the exact marker, + and a source-pinned opaque cursor; explicitly requiring a transcript returned + `transcript_required`. + - The first OpenCode read exposed its pane-bound Dispatch capability in terminal text. After the + fix and a clean runtime rebuild, the repeated physical read replaced it with + `[dispatch capability redacted]`, emitted an explicit warning, and contained no raw token. + - Focused Native Chat/orchestration output tests passed 49 tests and Node typecheck passed. +- Findings: + - Provider support and structured-output support remain separate: OpenCode orchestration is fully + usable without inventing an OpenCode transcript adapter. + - Terminal fallback is an orchestration output boundary and needs the same narrow secret + redaction as structured output; this does not change direct terminal-read behavior. +- Next: + - Run the complete orchestration regression selection and repository quality gates, then commit + and push the provider dogfood fixes. + +### 2026-07-25 — CI ask-admission fixture correction + +- Changes: + - Updated the WebSocket long-poll admission tests to place each simulated asking worker in a real + Run with an active supervised Dispatch. + - Kept the production rule that unsupervised workers cannot create blocking questions. +- Verification: + - The complete runtime RPC test file passed 59 tests. + - The broader orchestration/RPC/CLI selection passed 23 files and 391 tests. + - Node typecheck and `git diff --check` passed. +- Findings: + - The red CI assertions were stale test setup: old arbitrary terminal handles now fail + `orchestration.ask` before holding an admission slot, exactly as the new contract requires. +- Next: + - Push the test-only correction and confirm the replacement PR check is green. + +### 2026-07-26 — Hard orchestration contract cutover + +- Changes: + - Added one shared orchestration contract version, runtime capability, and mutation classifier. + - Fenced old, missing, and wrong-contract mutations before parsing, durable receipts, database + writes, process actions, prompt injection, and connected-server effects. + - Propagated the contract through Unix/named-pipe, WebSocket, connected-server, and SSH CLI + transports, with capability preflight before local or federated mutations. + - Retired the legacy scheduler commands locally and at RPC dispatch, preserving only explicit + read-only legacy inspection. + - Returned no-effects plus argument-only full-skill recovery and documented that pre-upgrade + worker processes continue unsupervised until inspected. +- Verification: + - Focused orchestration/federation selection: 56 files and 721 tests passed. + - Repository suite excluding the independently reproducible system-SSH native-installer timeout: + 3,479 files and 37,181 tests passed. + - Node, CLI, and web typechecks passed. + - Relay, CLI, Electron/Vite, and web production builds passed. + - Bundled-skill verification, reliability gates, max-lines ratchet, and `git diff --check` passed. + - The newer tracked HTML contains no named references to the audited orchestration projects; the + older redesign HTML remains ignored and untracked. +- Findings: + - A hard version fence plus executable skill recovery is simpler and safer than maintaining a + legacy executor or draining in-flight legacy state. + - Existing pre-upgrade processes are deliberately left alive, but rejected lifecycle calls + cannot mutate current Task, Dispatch, or inbox state. + - Full lint still reports the pre-existing localization audit for six unchanged `Ghostty` + keyword strings; the excluded system-SSH test independently times out while installing native + dependencies. Neither baseline issue is changed by this migration. +- Next: + - Commit and push as `OrcaWin`, then inspect branch-head CI and mark the final remote acceptance + item only after those checks settle. + +### 2026-07-27 — Current-main rebase and CLI registry integration + +- Changes: + - Rebased the 26 orchestration commits onto current `origin/main`. + - Preserved both main's active-worktree plugin context and orchestration's terminal process + incarnation and launcher validation in their one overlapping runtime conflict. + - Registered the current Run, worker, and retired-coordinator handler keys in main's new lazy CLI + handler-group manifest. + - Removed one trailing-whitespace artifact from the structured-output design header. +- Verification: + - Conflict-focused runtime, federation, migration, and transport selection: 6 files and 995 tests + passed. + - Handler manifest, registry parity, and CLI integration: 3 files and 169 tests passed. + - Repository suite excluding the independently reproducible system-SSH native-installer timeout: + 3,586 files and 37,864 tests passed. + - Node, CLI, and web typechecks, bundled-skill verification, reliability gates, max-lines + ratchet, and conflict-marker audit passed. +- Findings: + - The rebase itself had one additive method-placement conflict; neither behavior needed redesign. + - Main's lazy handler manifest is an additional command-registration source of truth, so every new + exported orchestration handler must be listed there. + - Tests added on main depend on newly patched packages; refreshing from the rebased lockfile was + required before their results were meaningful. +- Next: + - Push the rebased branch as `OrcaWin` and inspect replacement branch-head CI. + +### Entry template + +```text +### YYYY-MM-DD — Short implementation milestone + +- Changes: + - ... +- Files: + - `path` +- Verification: + - command/test and result +- Findings: + - decision, surprise, or risk +- Next: + - one concrete next step +``` diff --git a/ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md b/ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md new file mode 100644 index 00000000000..e7d9443cd95 --- /dev/null +++ b/ORCHESTRATION_STRUCTURED_OUTPUT_DESIGN.md @@ -0,0 +1,604 @@ +# Orchestration Structured Worker Output + +Status: implemented; physical local, mixed-version, and Windows-home to Mac-worker validation complete +Scope: orchestration `worker-read` only +Last updated: 2026-07-24 + +## Summary + +`orca orchestration worker-read` currently reads bounded terminal output. That is always available, +but full-screen agent TUIs can make it noisy or incomplete. + +Orca already knows more than the terminal logo suggests. Agent hooks associate an exact pane with: + +- the agent type, such as Codex or Claude; +- the provider session or conversation ID; and +- when available, the provider-reported transcript path. + +The sidebar, session resume, sleeping-agent recovery, and native chat already use this information. +The missing piece is a narrow orchestration path from an exact Dispatch to that exact session on the +server where the worker runs. + +The proposed behavior is: + +```text +worker-read + exact supported transcript is available -> structured transcript page + otherwise -> bounded terminal page +``` + +This does not add orchestration strategy, a dashboard, a scheduler, or a universal provider layer. +It makes one existing observation command return the best source Orca can prove. + +## User-facing goal + +A coordinator should be able to inspect a worker with one predictable command: + +```bash +orca orchestration worker-read --dispatch --json +``` + +The coordinator should not need to know: + +- which Orca server owns the worker; +- the worker's terminal or pane handle; +- the provider session ID; +- where a transcript lives on disk; or +- whether structured reading is supported by that provider/server version. + +The response must always say which source was used. It must never silently read a different agent +session. + +## Why the existing sidebar is relevant + +The agent logo identifies the detected agent type. By itself, that is not enough to choose a +transcript. + +The richer sidebar status also carries pane-scoped provider-session metadata reported by hooks. +That is the useful foundation: + +```text +Dispatch + -> exact worker process and terminal + -> exact tab/leaf pane + -> hook-reported provider session + -> provider transcript locator +``` + +Some live status ownership is currently renderer-centric, while headless and mobile graph paths +also retain compatible status snapshots. Implementation therefore needs one runtime-owned lookup +that exposes the current exact pane association to `worker-read`. This is a small bridge over +existing status data, not a second agent-status system. + +## Design principles + +### Exactness over convenience + +- Never select the "latest session in this directory." +- Never select a transcript from a terminal title or logo alone. +- Never switch sources or sessions in the middle of a cursor chain. +- If Orca cannot prove the association, return a labeled terminal fallback. + +### Resource-local reads + +The server running the worker resolves and reads its transcript. A Run home on macOS must not try +to interpret a Windows path, and a Windows Run home must not try to interpret a macOS path. + +Only bounded output data crosses the federation connection. Transcript paths do not. + +### One simple agent command + +Agents should not choose provider adapters or supply session metadata. `worker-read` defaults to +automatic source selection. Source selection flags exist for debugging and explicit policy, not +because they are required in the normal loop. + +### Narrow provider support + +Initial support should cover only providers for which Orca already has: + +1. an exact pane-scoped session association; and +2. an existing bounded transcript reader with test coverage. + +Codex is the required first provider. Claude may ship in the same change only if it uses the same +proven reader path without adding a second architecture. Other agents receive terminal fallback. + +### Honest compatibility + +Connected servers can run different Orca versions. A server without structured-read support must +continue to return bounded terminal output rather than failing the whole Run. + +## Public command contract + +### Request + +```bash +orca orchestration worker-read \ + --dispatch \ + [--source auto|transcript|terminal] \ + [--cursor ] \ + [--limit ] \ + [--json] +``` + +`--source` behavior: + +| Value | Behavior | +| ------------ | -------------------------------------------------------------------------------------- | +| `auto` | Use an exact supported transcript; otherwise use terminal output. This is the default. | +| `transcript` | Require an exact supported transcript. Return a typed error instead of falling back. | +| `terminal` | Use the current bounded terminal reader. | + +Existing numeric terminal cursors remain accepted. New responses return an opaque cursor that can +pin either source without exposing provider paths. + +### Response + +The implemented structured response has this shape: + +```json +{ + "dispatchId": "dispatch_123", + "source": "transcript", + "sourceIdentity": "opaque-source-fingerprint", + "provider": "codex", + "transcript": { + "messages": [], + "nextCursor": "opaque-next-cursor", + "limited": false, + "returnedMessageCount": 0 + }, + "cursor": "opaque-next-cursor", + "status": { + "worker": "running", + "terminal": "running" + }, + "fallbackReason": null, + "warnings": [] +} +``` + +Terminal fallback uses the same envelope and keeps the existing terminal data: + +```json +{ + "dispatchId": "dispatch_123", + "source": "terminal", + "sourceIdentity": "opaque-terminal-incarnation", + "terminal": { + "tail": ["..."], + "status": "running", + "nextCursor": "..." + }, + "cursor": "opaque-next-cursor", + "fallbackReason": "session_not_reported", + "warnings": [] +} +``` + +For backward compatibility: + +- the terminal branch retains the existing `terminal.tail`, `terminal.status`, and + `terminal.nextCursor` fields; +- non-JSON output prints readable transcript entries or terminal lines without requiring an agent + to branch on JSON manually; +- a mixed-version federated read can return the legacy terminal shape, which the Run home wraps as + a labeled terminal response. + +### Fallback reasons + +Fallback reasons are bounded typed values, not arbitrary policy: + +- `provider_unsupported` +- `session_not_reported` +- `transcript_missing` +- `transcript_unreadable` +- `transcript_parse_failed` +- `remote_capability_unavailable` + +Warnings may provide safe diagnostic context, but must not contain a remote filesystem path. + +### Typed errors + +Errors are used when returning any source would be misleading: + +- `dispatch_not_found` +- `worker_identity_changed` +- `source_changed` +- `cursor_invalid` +- `cursor_dispatch_mismatch` +- `transcript_required` +- the existing connected-server unavailable/unknown result + +`source_changed` means the exact pane is now associated with a different provider session than the +one pinned by the cursor. The caller starts a new read without the old cursor. Orca never silently +jumps to the replacement session. + +## Source identity and cursor rules + +The cursor is opaque to clients and contains only non-sensitive routing data: + +- cursor version; +- Dispatch ID; +- source kind; +- an opaque digest of the exact source identity; +- provider-specific or terminal paging position. + +It must not contain a transcript path. + +On every continued read, the worker server: + +1. revalidates the Dispatch's exact process attachment; +2. resolves the current pane/session association; +3. compares its source digest with the cursor; +4. reads only when they still match; and +5. otherwise returns `source_changed` or `worker_identity_changed`. + +The cursor is source-pinned even when the request uses `--source auto`. `auto` chooses only on the +first page. + +The implementation uses a versioned stateless token. The token is not an authority credential: +every read revalidates the Dispatch, process, pane, source digest, and provider session before +returning data. This lets paging survive an Orca restart without adding cursor-secret persistence. + +## Runtime architecture + +### 1. Resolve the Dispatch at its Run home + +The Run home remains authoritative for Task and Dispatch state. It looks up the Dispatch and its +pinned worker server exactly as `worker-show` and the current `worker-read` do. + +No automatic placement or server selection is added. + +Current implementation anchors: + +- `src/main/runtime/rpc/methods/orchestration-worker-control.ts` owns local and federated + `worker-read` routing. +- `src/main/runtime/rpc/methods/orchestration-worker-observation.ts` validates the exact attached + worker. +- `src/cli/handlers/orchestration.ts` owns the current CLI request and terminal rendering. + +### 2. Route the read to the worker server + +For a local worker, the Run home and worker server are the same runtime. + +For a federated worker, the Run home calls the existing federation read route on the server pinned +to the Dispatch. The request contains the Dispatch ID, source preference, cursor, and limit—not a +terminal handle, session ID, or transcript path chosen by the coordinator. + +### 3. Revalidate the exact worker + +The worker server uses the existing Dispatch attachment to verify: + +- the exact managed pane; +- the exact terminal/process incarnation; and +- that the Dispatch has not been replaced, stopped, or detached. + +This preserves the same no-cross-worker rule already used by worker lifecycle and terminal reads. + +### 4. Resolve the pane's provider session + +Add one runtime-owned resolver that returns a snapshot similar to: + +```ts +type ExactWorkerProviderSession = { + paneKey: string + agent: TuiAgent + providerSession: AgentProviderSessionMetadata + observedAt: number +} +``` + +The resolver may use the current runtime graph/headless retained status, but it must accept the +Dispatch's exact pane identity as input. It must not search all sessions by working directory or +agent type. + +The source association is considered usable only when: + +- it belongs to the Dispatch's exact current pane/process; +- the hook metadata is fresh enough to belong to that process incarnation; +- the provider session metadata passes existing normalization/canonicalization; and +- a supported adapter can resolve an exact transcript. + +If these checks fail under `auto`, the read falls back to terminal output. + +Current session/status anchors: + +- `src/shared/agent-session-resume.ts` defines normalized provider-session metadata. +- `src/renderer/src/store/slices/agent-status.ts` maintains pane-scoped live agent status. +- `src/shared/runtime-types.ts` carries compatible agent status in runtime/mobile graph snapshots. +- `src/main/runtime/orca-runtime.ts` preserves provider-session metadata when it publishes those + snapshots. + +### 5. Read through a narrow adapter + +Reuse the existing bounded native-chat transcript parser rather than adding another parser stack. +Extract or wrap its pure reader behind a small orchestration adapter: + +```ts +type WorkerTranscriptReader = { + provider: 'codex' | 'claude' | 'openclaude' | 'grok' + readPage(input: ExactTranscriptRead): Promise +} +``` + +This is deliberately not a registry for every possible agent capability. Add an adapter only when +an exact locator and a tested reader already exist. + +Current reader anchors: + +- `src/main/native-chat/transcript-watch.ts` provides bounded transcript reads/subscriptions. +- `src/main/runtime/rpc/methods/native-chat.ts` exposes the existing reader over runtime RPC. +- `src/main/ipc/native-chat.ts` exposes the same reader to the desktop renderer. + +The transcript response should preserve the existing structured message/block representation and +the supported fields that the proven reader already understands. Unknown or skipped input should +produce parsing warnings rather than being silently presented as a complete transcript. + +### 6. Return bounded data + +Every path enforces: + +- at most 50 transcript messages per page (40 by default); +- a maximum serialized response size; +- existing clipping/redaction rules for large tool input and output; +- opaque projection of transcript-position fallback IDs and redaction of Dispatch capability tokens; +- deterministic pagination; and +- no transcript path leakage. + +Transcript observation is read-only. A failure or unknown network result must never trigger worker +restart, retry, stop, or Task mutation. + +## Federation and cross-platform behavior + +The core topology is: + +```text +Mac Run home + -> authenticated connected-server RPC + -> Windows worker server + -> exact Windows pane/session + -> Windows-local transcript reader + -> bounded structured page back to Mac +``` + +The reverse direction must work identically. + +Platform rules: + +- Use Node path operations only on the server that owns the path. +- Do not normalize Windows paths on macOS or macOS/Linux paths on Windows. +- SSH and WSL execution remain behind their owning Orca server. +- If the exact transcript is accessible only on an SSH/WSL execution host, the worker server must + use an existing host-aware read mechanism or fall back to terminal. Do not copy the path to the + Run home. +- Mixed-version capability negotiation applies only at the Orca server protocol boundary. +- A server that does not advertise structured worker read receives the existing terminal-read RPC. + +Federation adds one narrow additive RPC, `orchestration.federationReadOutput`. The Run home probes +it by calling it. If the worker server returns `method_not_found`, the Run home calls the existing +`orchestration.federationRead` terminal method and wraps that result as a labeled +`remote_capability_unavailable` fallback. No generalized capability matrix is added. + +## Lifecycle behavior + +### Provider session appears after worker start + +Hooks may report a session after the TUI becomes ready. An initial `auto` read may therefore return +terminal output. A later first-page `auto` read may select transcript output. + +Once a cursor is returned, that cursor remains pinned to its selected source. + +### Provider session changes + +Compaction, resume, or process replacement may produce a new provider session: + +- a fresh read without a cursor may select the new exact session; +- a cursor for the old session returns `source_changed`; +- Orca does not merge the old and new transcripts implicitly. + +### Orca restart + +After restart, the worker server re-establishes the exact pane/process association using the same +runtime graph and retained-hook mechanisms used by sidebar/session recovery. + +- If exact identity and session still match, paging continues. +- If process identity is uncertain, return `worker_identity_changed`. +- If only transcript identity is unavailable, `auto` may start a new terminal page but must not + reinterpret an old transcript cursor as a terminal cursor. + +### Disconnect + +A disconnected federated read is a read-only unknown result. Reissuing the same read is safe. +No mutation request ledger, durable outbox, automatic failover, or worker replacement is needed. + +## Implementation plan + +### Work package 1 — Correct the contract and types + +- Update the orchestration checklist to replace the inaccurate claim that exact pane-to-session + association does not exist. +- Add the source preference, response envelope, fallback enums, and opaque cursor types. +- Keep the existing terminal response fields compatible. +- Add the CLI `--source` option and accept both legacy numeric and new opaque cursors. + +Exit gate: contract tests cover legacy terminal JSON and the new labeled envelopes. + +### Work package 2 — Runtime exact-session resolver + +- Add a runtime method that resolves agent status for an exact Dispatch pane/process. +- Reuse the existing graph/headless retained status and provider-session normalization. +- Reject stale pane or process-incarnation associations. +- Test multiple panes and multiple sessions in the same worktree and directory. + +Exit gate: the resolver can never return a sibling pane's session. + +### Work package 3 — Codex transcript adapter + +- Reuse the current bounded native-chat Codex reader. +- Add deterministic page conversion and parsing warnings. +- Enforce entry and byte limits. +- Produce a path-free source identity digest and cursor. +- Add Claude only if it follows this same path without new infrastructure. + +Exit gate: exact Codex transcript pages are stable, bounded, and contain no local path. + +### Work package 4 — Local `worker-read` + +- Resolve and validate the exact worker. +- Implement `auto`, `transcript`, and `terminal`. +- Pin the source across cursor pages. +- Preserve the existing terminal fallback and non-JSON rendering. + +Exit gate: local dogfood proves correct selection with several simultaneous same-directory Codex +sessions. + +### Work package 5 — Federated `worker-read` + +- Add the narrow connected-server capability. +- Route transcript resolution and reading to the worker server. +- Wrap legacy remote terminal responses as labeled fallbacks. +- Reject mismatched Dispatch/server/session cursors. +- Ensure paths and internal server identity remain out of ordinary output. + +Exit gate: physical Mac-to-Windows and Windows-to-Mac reads both pass. + +### Work package 6 — Restart, fallback, and documentation + +- Cover runtime restart, renderer restart, disconnect, stale status, missing hooks, unreadable + transcript, unsupported providers, and mixed server versions. +- Update CLI help, the orchestration skill, and the implementation checklist. +- Dogfood the common coordinator loop using only the documented commands. + +Exit gate: every fallback is truthful and no fallback changes worker lifecycle state. + +## Validation plan + +### Unit and contract tests + +| Area | Required proof | +| --------------------- | ------------------------------------------------------------------------------------ | +| Exact resolution | A Dispatch resolves only its attached pane and process incarnation. | +| No directory guessing | Two Codex sessions in the same worktree cannot cross-read. | +| Source choice | `auto` prefers an exact supported transcript and otherwise labels terminal fallback. | +| Explicit source | `transcript` fails truthfully when unavailable; `terminal` never probes transcript. | +| Cursor pinning | Continued pages stay on the same source and provider session. | +| Session replacement | An old cursor returns `source_changed`. | +| Cursor custody | A cursor for another Dispatch is rejected. | +| Parsing | Malformed/skipped transcript records produce bounded warnings. | +| Limits | Entry count, block size, and total serialized response are bounded. | +| Privacy | Responses and cursors contain no transcript path. | +| Compatibility | Existing terminal fields and numeric cursors continue to work. | + +Likely focused test locations: + +- provider-session normalization and pane association tests; +- native-chat transcript reader tests; +- orchestration worker-control RPC tests; +- orchestration worker CLI tests; and +- federation protocol and physical harness tests. + +### Local integration scenarios + +1. Start a Codex worker and confirm the sidebar reports its provider session. +2. Read the Dispatch and verify `source=transcript`. +3. Start two Codex workers in the same worktree. +4. Give them distinct prompts and verify neither read contains the other's content. +5. Page both transcripts and verify stable source identities. +6. replace or resume one session and verify its old cursor returns `source_changed`. +7. Disable hooks and verify a labeled terminal fallback. +8. Remove or make the transcript unreadable and verify a safe fallback or + `transcript_required`, depending on the requested source. + +### Physical federation matrix + +| Run home | Worker server | Worker location | Required outcome | +| -------- | ------------- | --------------- | --------------------------------------------------------------- | +| macOS | Windows | native Windows | Exact structured page or labeled supported fallback | +| Windows | macOS | native macOS | Exact structured page or labeled supported fallback | +| macOS | macOS/Linux | SSH host | Exact host-aware page or terminal fallback without path leakage | +| Windows | Windows | WSL | Exact host-aware page or terminal fallback without path leakage | + +For both Mac/Windows directions: + +- run multiple workers at once; +- page beyond the first response; +- restart the Run home; +- restart the worker server; +- disconnect and reconnect the server; +- verify mixed-version fallback with one server lacking the new capability; and +- compare the selected provider session with the sidebar/native-chat session for the same pane. + +### Dogfood procedure + +The dogfood is successful only if a coordinator can follow this loop without internal IDs: + +1. Create/use a Run. +2. Start one local worker and one connected-server worker. +3. Wait for both starts to settle. +4. Call `worker-read --source auto` for each Dispatch. +5. Continue each cursor through at least two pages. +6. Confirm output belongs to the correct prompt and machine. +7. Trigger one fallback case. +8. Complete both workers and confirm reads never altered lifecycle state. + +Record: + +- command and response; +- chosen source and fallback reason; +- worker server/platform; +- provider and session match; +- cursor behavior; +- path-leak check; +- restart/disconnect outcome; and +- any agent confusion using only CLI help and the orchestration skill. + +## Acceptance criteria + +Implementation is complete only when: + +- `worker-read` selects an exact transcript or returns a clearly labeled terminal fallback. +- No test or dogfood scenario reads a sibling or previous provider session. +- A cursor never switches source or provider session silently. +- Mac-to-Windows and Windows-to-Mac physical reads pass. +- Runtime restart and disconnect behavior are safe and understandable. +- Unsupported agents and mixed versions retain useful terminal output. +- Transcript paths never leave the server that owns them. +- Existing terminal-read clients remain compatible. +- The common agent path remains one command with no server/session/path inputs. +- No UI, scheduling, retry, integration tracking, or generalized provider framework is added. + +## Explicit non-goals + +- No dashboard or sidebar changes. +- No coordinator chat changes. +- No automatic worker placement, retry, replacement, or recovery. +- No commit, test, branch, merge, or integration tracking. +- No provider-session locking or resume orchestration. +- No live transcript subscription in the orchestration API. +- No universal transcript/event ontology. +- No cross-server filesystem access from the Run home. +- No replicated Run database or automatic Run-home failover. +- No generalized access-control or capability framework. + +## Main risks and mitigations + +| Risk | Mitigation | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| Live session status is stale or renderer-owned | Resolve through a runtime-owned exact-pane snapshot and bind it to process incarnation. | +| Two sessions share a directory | Never use directory/latest-session lookup; require exact pane/session metadata. | +| A session changes between pages | Pin source identity in the cursor and return `source_changed`. | +| Remote path is meaningless or sensitive | Read only on the worker server and never serialize the path. | +| Transcript parser drops data | Preserve supported structured blocks and return parsing warnings. | +| Transcript metadata exposes a path/credential | Make file-position IDs opaque and redact Dispatch capabilities from all structured text/payloads. | +| Mixed server versions | Negotiate one narrow capability and fall back to existing terminal read. | +| Full-screen terminal output remains noisy | Prefer structured output only when exact; retain terminal as the universal safety path. | +| Scope expands into a provider platform | Ship Codex first and require proven exact association plus an existing reader for every addition. | + +## Decision + +Implement structured worker output as a narrow extension of `worker-read`. + +The prerequisite is not a new sidebar or status system: Orca already tracks exact pane-scoped +provider sessions. The work is to make that existing association available to the worker-owning +runtime, read the transcript locally through proven readers, pin pagination to that source, and +federate only the bounded result. diff --git a/build-plugins/plain-node-entry-guard.ts b/build-plugins/plain-node-entry-guard.ts index 81abb08cf79..a7ad171a402 100644 --- a/build-plugins/plain-node-entry-guard.ts +++ b/build-plugins/plain-node-entry-guard.ts @@ -1,6 +1,10 @@ import { spawnSync } from 'node:child_process' import { join } from 'node:path' -import type { NormalizedOutputOptions, OutputBundle, OutputChunk, Plugin } from 'rollup' +import type { Plugin, Rollup } from 'vite' + +type NormalizedOutputOptions = Rollup.NormalizedOutputOptions +type OutputBundle = Rollup.OutputBundle +type OutputChunk = Rollup.OutputChunk // Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main // chunk gained `require("electron")` (an import edge added in #7642), and the @@ -97,6 +101,8 @@ function smokeLoadDaemonEntry(outputDir: string): void { } export function createPlainNodeEntryGuardPlugin(): Plugin { + let daemonOutputDir: string | undefined + return { name: 'orca-plain-node-entry-guard', writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) { @@ -124,7 +130,14 @@ export function createPlainNodeEntryGuardPlugin(): Plugin { } if (entryByName.has('daemon-entry') && options.dir) { - smokeLoadDaemonEntry(options.dir) + daemonOutputDir = options.dir + } + }, + closeBundle() { + if (daemonOutputDir) { + const outputDir = daemonOutputDir + daemonOutputDir = undefined + smokeLoadDaemonEntry(outputDir) } } } diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 1f6246ec531..e731cb7f1d8 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -12,9 +12,13 @@ const { verifyPackagedMainRuntimeDeps } = require('./packaged-runtime-node-modules.cjs') const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cjs') +const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs') +const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs') const isMacRelease = process.env.ORCA_MAC_RELEASE === '1' const isLinuxArm64Release = process.env.ORCA_LINUX_ARM64_RELEASE === '1' +const localBuildVersion = isMacRelease ? undefined : process.env.ORCA_LOCAL_BUILD_VERSION +const appId = 'com.stablyai.orca' const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' @@ -32,11 +36,17 @@ const relayExtraResource = { from: 'out/relay', to: 'relay' } +// Why: bundled plugins are immutable install inputs and must remain ordinary +// directories so the startup bootstrap can verify and publish exact bytes. +const bundledPluginResources = { + from: 'resources/plugins/launch', + to: 'plugins/launch' +} // Why: the main bundle, packaged CLI, SSH paths, and speech worker all execute // from package directories where pnpm's symlink farm is absent. Copy the exact // runtime dependency closure to Resources/node_modules so bare require() calls // do not fall through to a developer checkout's node_modules. -const commonExtraResources = [relayExtraResource, skillFreshnessResources] +const commonExtraResources = [relayExtraResource, bundledPluginResources, skillFreshnessResources] const macSpeechNativeResource = { from: 'node_modules/sherpa-onnx-darwin-${arch}', to: 'node_modules/sherpa-onnx-darwin-${arch}' @@ -52,8 +62,9 @@ const winSpeechNativeResource = { /** @type {import('electron-builder').Configuration} */ module.exports = { - appId: 'com.stablyai.orca', + appId, productName: 'Orca', + ...(localBuildVersion ? { extraMetadata: { version: localBuildVersion } } : {}), directories: { buildResources: 'resources/build' }, @@ -79,6 +90,8 @@ module.exports = { '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', '!out/**/*.test.js', + // Why: Vite's manifest is only used to project the paired web client. + '!out/renderer/.vite{,/**/*}', '!electron.vite.config.{js,ts,mjs,cjs}', '!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,CHANGELOG.md,README.md}', '!{.env,.env.*,.npmrc,pnpm-lock.yaml}', @@ -87,6 +100,9 @@ module.exports = { // it from process.resourcesPath; exclude the source copy from app.asar. '!resources/onboarding/feature-wall/**', '!resources/skills/**', + // Why: bundled plugins ship via extraResources to resources/plugins/launch; + // packing the source tree into app.asar would duplicate those exact bytes. + '!resources/plugins/launch/**', // Why: the Windows CLI shim ships via extraResources to resources/bin/orca.cmd // (beside the native resources/bin/orca.exe). Packing the source tree into // app.asar too lets asarUnpack:['resources/**'] extract a second copy at @@ -129,6 +145,7 @@ module.exports = { 'out/main/hermes/**', 'out/main/win32-utils.js', 'out/main/daemon-entry.js', + 'out/main/plugin-host-entry.js', 'out/main/computer-sidecar.js', 'out/main/parcel-watcher-process-entry.js', 'out/main/chunks/**', @@ -158,6 +175,25 @@ module.exports = { if (!existsSync(resourcesDir)) { return } + if (context.electronPlatformName === 'darwin') { + const architectureByEnum = { 1: 'x64', 3: 'arm64' } + const architecture = architectureByEnum[context.arch] + if (!architecture) { + throw new Error(`Unsupported local-build compatibility architecture: ${context.arch}`) + } + const version = context.packager.appInfo.version + let commit = process.env.ORCA_BUILD_COMMIT || process.env.GITHUB_SHA || 'unknown' + if (commit === 'unknown') { + try { + commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + } catch { + // Source archives can still produce a signed build with an explicit version. + } + } + writeMacBuildCompatibility(resourcesDir, { version, commit, architecture }) + } prunePackagedRuntimeNodeModules(resourcesDir, context.electronPlatformName, context.arch) verifyPackagedMainRuntimeDeps(resourcesDir) // Why: boot the packaged daemon-entry under plain Node, but only for the @@ -178,6 +214,9 @@ module.exports = { `[verify-packaged-daemon-entry] skipped boot on cross-arch slice (target ${context.arch}, host ${process.arch})` ) } + // Why: inspect electron-builder's real output so a broken extraResources + // mapping fails packaging before bundled content reaches users. + verifyPackagedPluginResources(resourcesDir) chmodUnixCliLaunchers(resourcesDir, context.electronPlatformName) chmodMacServeSimHelpers(resourcesDir, context.electronPlatformName) for (const filename of readdirSync(resourcesDir)) { diff --git a/config/electron-vite-target.config.ts b/config/electron-vite-target.config.ts new file mode 100644 index 00000000000..60651dd6590 --- /dev/null +++ b/config/electron-vite-target.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'electron-vite' +import { electronViteConfig } from '../electron.vite.config' + +const target = process.env.ORCA_ELECTRON_VITE_TARGET +const configByTarget = { + main: { main: electronViteConfig.main }, + preload: { preload: electronViteConfig.preload }, + renderer: { renderer: electronViteConfig.renderer } +} + +if (!target || !Object.prototype.hasOwnProperty.call(configByTarget, target)) { + throw new Error(`Invalid ORCA_ELECTRON_VITE_TARGET: ${target ?? ''}`) +} + +export default defineConfig(configByTarget[target as keyof typeof configByTarget]) diff --git a/config/localization-coverage-allowlist.json b/config/localization-coverage-allowlist.json index b40959e7e03..2a3b4603f36 100644 --- a/config/localization-coverage-allowlist.json +++ b/config/localization-coverage-allowlist.json @@ -40,5 +40,33 @@ "text": "Idioma", "dynamic": false, "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", + "kind": "object-property:keywords", + "text": "Ghostty", + "dynamic": false, + "count": 2 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-advanced-platform-search.ts", + "kind": "object-property:keywords", + "text": "ghostty", + "dynamic": false, + "count": 2 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "kind": "object-property:keywords", + "text": "Ghostty", + "dynamic": false, + "count": 1 + }, + { + "filePath": "src/renderer/src/components/settings/terminal-pane-appearance-search.ts", + "kind": "object-property:keywords", + "text": "ghostty", + "dynamic": false, + "count": 1 } ] diff --git a/config/patches/@xterm__xterm@6.1.0-beta.287.patch b/config/patches/@xterm__xterm@6.1.0-beta.287.patch index 3847fbfbb60..e3ee0376a13 100644 --- a/config/patches/@xterm__xterm@6.1.0-beta.287.patch +++ b/config/patches/@xterm__xterm@6.1.0-beta.287.patch @@ -1,25 +1,26 @@ diff --git a/lib/xterm.js b/lib/xterm.js -index 9602d2a2abbdd6eb1ed884dd2cdcace32d1bb1a3..eecf435886d866a838430b9f81f713deed3eb8c1 100644 +index 9602d2a2abbdd6eb1ed884dd2cdcace32d1bb1a3..2a2bab8beb8a2d412f4349b8338ed3235432a1a2 100644 --- a/lib/xterm.js +++ b/lib/xterm.js @@ -1,2 +1,2 @@ -!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,()=>(()=>{"use strict";var e={2840(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new c.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(e=>this._handleResize(e.rows))),this._register(this._terminal.onRender(e=>this._refreshRows(e.start,e.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(e=>this._handleChar(e))),this._register(this._terminal.onLineFeed(()=>this._handleChar("\n"))),this._register(this._terminal.onA11yTab(e=>this._handleTab(e))),this._register(this._terminal.onKey(e=>this._handleKey(e.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register((0,f.addDisposableListener)(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,d.toDisposable)(()=>{this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent=l.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],o=e?.translateToString(!0,void 0,void 0,t)||"",n=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===o.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=o,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",n),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent===l.tooMuchOutput.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,o;if(0===t?(r=i,o=this._rowElements.pop(),this._rowContainer.removeChild(o)):(r=this._rowElements.shift(),o=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),o.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let o=t=this._terminal.cols&&(++s,o=0),{row:s,column:o}},o=r(t),n=r(i);if(o&&n){if(o.row>n.row||o.row===n.row&&o.column>=n.column)throw new Error("invalid range");this._terminal.select(o.column,o.row,(n.row-o.row)*this._terminal.cols-o.column+n.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;ethis._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(e=>this._reportWindowsOptions(e))),this._register(this._inputHandler.onColor(e=>this._handleColorEvent(e))),this._register(I.EventUtils.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(I.EventUtils.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(I.EventUtils.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(I.EventUtils.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(e=>this._afterResize(e.cols,e.rows))),this._register((0,N.toDisposable)(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i;switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=E.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`]${i};${(0,M.toRgbString)(s)}\\`);break;case 1:if("ansi"===e)this._themeService.modifyColors(e=>e.ansi[t.index]=E.channels.toColor(...t.color));else{const i=e;this._themeService.modifyColors(e=>e[i]=E.channels.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index)}}}_reportColorScheme(){if(!this._themeService)return;const e=E.rgb.relativeLuminance(this._themeService.colors.background.rgba>>8)>8)?1:2;this.coreService.triggerDataEvent(`[?997;${e}n`)}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),o=this._renderService.dimensions.css.cell.width*r,n=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=n+"px",this.textarea.style.width=o+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,H.addDisposableListener)(this.element,"copy",e=>{this.hasSelection()&&(0,a.copyHandler)(e,this._selectionService)}));const e=e=>(0,a.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,H.addDisposableListener)(this.textarea,"paste",e)),this._register((0,H.addDisposableListener)(this.element,"paste",e)),x.isFirefox?this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>{2===e.button&&(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register((0,H.addDisposableListener)(this.element,"contextmenu",e=>{(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),x.isLinux&&this._register((0,H.addDisposableListener)(this.element,"auxclick",e=>{1===e.button&&(0,a.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)}))}_bindKeys(){this._register((0,H.addDisposableListener)(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register((0,H.addDisposableListener)(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register((0,H.addDisposableListener)(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register((0,H.addDisposableListener)(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",e=>this.element.classList.toggle("allow-transparency",e))),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,H.addDisposableListener)(this.screenElement,"mousemove",e=>this.updateCursorStyle(e))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",h.promptLabel.get()),x.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(g.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??("undefined"!=typeof window?window.document:null))),this._instantiationService.setService(C.ICoreBrowserService,this._coreBrowserService),this._register((0,H.addDisposableListener)(this.textarea,"focus",e=>this._handleTextAreaFocus(e))),this._register((0,H.addDisposableListener)(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(p.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(C.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(k.ThemeService),this._instantiationService.setService(C.IThemeService,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(v.CharacterJoinerService),this._instantiationService.setService(C.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(w.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(C.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange(e=>this._onRender.fire(e))),this._register(this._renderService.onDimensionsChange(e=>this._onDimensionsChange.fire({css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}))),this.onResize(e=>this._renderService.resize(e.cols,e.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(u.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(S.MouseCoordsService),this._instantiationService.setService(C.IMouseCoordsService,this._mouseCoordsService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(O.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(e){this._logService.error("onWillOpen handler threw an exception",e)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(c.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(y.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(C.ISelectionService,this._selectionService),this._mouseService=this._instantiationService.createInstance(b.MouseService),this._instantiationService.setService(C.IMouseService,this._mouseService),this._register(this._selectionService.onRequestScrollLines(e=>this.scrollLines(e.amount,e.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()})),this._register(I.EventUtils.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(d.BufferDecorationRenderer,this.screenElement)),this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>this._selectionService.handleMouseDown(e))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",e=>this._handleScreenReaderModeOptionChange(e)));const r=this.options.scrollbar?.showScrollbar??!0,o=this.options.scrollbar?.width;r&&o&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",e=>{const t=(e?.showScrollbar??!0)&&!!e?.width;!this._overviewRulerRenderer&&t&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:e=>this._viewport?.handleTouchScroll(e)},e=>this._register(e),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(f.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,a.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),e.preventDefault(),e.stopPropagation(),!1}if(1===i.type&&this.selectAll(),this._isThirdLevelShift(this.browser,e))return!0;if(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)return!0;if(!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;""!==i.key&&"\r"!==i.key||(this.textarea.value="");const s=this._keyboardService.useWin32InputMode&&W(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return;W(e)||this.focus();const t=this._keyboardService.evaluateKeyUp(e);if(t?.key){const i=this._keyboardService.useWin32InputMode&&W(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;efunction(e){const t=l(e);for(t.animFrameRequested=!1,t.current=t.next,t.next=[],t.inAnimationFrameRunner=!0;t.current.length>0;)t.current.sort(a.sort),t.current.shift().execute();t.inAnimationFrameRunner=!1}(e))),r};const s=i(3132);function r(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView;const i=e;return i?.view?i.view:window}class o{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s,e.addEventListener(t,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function n(e,t,i,s){return new o(e,t,i,s)}t.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class a{constructor(e,t){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){console.error(e)}}static sort(e,t){return t.priority-e.priority}}const h=new Map;function l(e){let t=h.get(e);return t||(t={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},h.set(e,t)),t}class c extends s.IntervalTimer{constructor(e){super(),this._defaultTarget=e?r(e):void 0}cancelAndSet(e,t,i){super.cancelAndSet(e,t,i??this._defaultTarget??window)}}t.WindowIntervalTimer=c},8906(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const o=i(4812),n=i(6501),a=i(7098),h=i(8636),l=i(4159);let c=class extends o.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseCoordsService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new h.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new h.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,o.toDisposable)(()=>{(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register((0,l.addDisposableListener)(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register((0,l.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach(e=>{e.link.dispose&&e.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,t=>{if(this._isMouseOut)return;const r=t?.map(e=>({link:e}));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=o;e<=n;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element);e&&this._askForLink(e,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t){const i=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseCoordsService),r(2,a.IRenderService),r(3,n.IBufferService),r(4,a.ILinkProviderService)],c)},7721(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const o={get:()=>r,set:e=>r=e};t.tooMuchOutput=o},3285(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const o=i(3055),n=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new o.CellData}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=this._workCell,n=i.getTrimmedLength();let a=-1,l=-1,c=!1;for(let t=0;tr?r.activate(e,t,d):h(0,t),hover:(e,t)=>r?.hover?.(e,t,d),leave:(e,t)=>r?.leave?.(e,t,d)})}c=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,a=o.extended.urlId):(l=-1,a=-1)}}t(s)}_getRangeWithLineWrap(e,t,i,s){let r=e,o=t,n=e,a=i;for(;0===o;){const e=this._bufferService.buffer.lines.get(r-1);if(!e?.isWrapped)break;const t=this._bufferService.buffer.lines.get(r-2);if(!t)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,i-1,s))break;let n=i-1;for(;n>0&&this._hasUrlId(t,n-1,s);)n--;r--,o=n}for(;;){const e=this._bufferService.buffer.lines.get(n-1);if(!e)break;if(a!==e.getTrimmedLength())break;const t=this._bufferService.buffer.lines.get(n);if(!t?.isWrapped)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,0,s))break;let r=1;for(;rthis._innerRefresh()),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)void 0!==this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],o=t[i/6%6|0],n=t[i%6];e.push({css:s.channels.toCss(r,o,n),rgba:s.channels.toRgba(r,o,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const o=i(7098),n=i(4812),a=i(6501),h=i(4159),l=i(8566),c=i(8636),d=i(7880);let _=class extends n.Disposable{constructor(e,t,i,s,r,o,a,_,u){super(),this._bufferService=i,this._coreService=r,this._optionsService=_,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1,this._needsSyncOnRender=!1;const f=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,h.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{f.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new l.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},f)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(o.onProtocolChange(e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{e.style.backgroundColor=a.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=a.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,n.toDisposable)(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,n.toDisposable)(()=>this._styleElement.remove())),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${a.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${a.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${a.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(e=>this._handleScroll(e)))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){const e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,i=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:i,verticalHasArrows:t}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._coreService.decPrivateModes.synchronizedOutput?this._needsSyncOnRender=!0:(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1))}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}handleTouchScroll(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};t.Viewport=_,t.Viewport=_=s([r(2,a.IBufferService),r(3,o.ICoreBrowserService),r(4,a.ICoreService),r(5,a.IMouseStateService),r(6,o.IThemeService),r(7,a.IOptionsService),r(8,o.IRenderService)],_)},4196(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const o=i(7098),n=i(4812),a=i(6501);let h=class extends n.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(e=>this._removeDecoration(e))),this._register((0,n.toDisposable)(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h,t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,o.ICoreBrowserService),r(3,a.IDecorationService),r(4,o.IRenderService)],h)},957(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const o=i(957),n=i(7098),a=i(4812),h=i(6501),l={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=class extends a.Disposable{get _width(){const e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??1?e?.width??0:0}constructor(e,t,i,s,r,n,h,l){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=h,this._coreBrowserService=l,this._colorZoneStore=new o.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)(()=>this._canvas?.remove()));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register((0,a.toDisposable)(()=>{void 0!==this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;const e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_,t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,n.IRenderService),r(5,h.IOptionsService),r(6,n.IThemeService),r(7,n.ICoreBrowserService)],_)},3618(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const o=i(7098),n=i(6501);let a=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,o){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=o,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent=""}compositionstart(){this._isComposing=!0;const e=this._textarea.selectionStart??this._textarea.value.length,t=this._textarea.selectionEnd??e;this._compositionPosition.start=Math.min(e,t),this._compositionPosition.end=Math.max(e,t),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=`‎${e.data}‎`,this.updateCompositionElements(),setTimeout(()=>{const e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){let i;if(this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const s=this._textarea.value,r=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;i=s.substring(e.start,Math.max(e.start,r))}i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;const e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0),0)}}};t.CompositionHelper=a,t.CompositionHelper=a=s([r(2,n.IBufferService),r(3,n.IOptionsService),r(4,n.ICoreService),r(5,o.IRenderService)],a)},5251(e,t){function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),o=parseInt(r.getPropertyValue("padding-left"),10),n=parseInt(r.getPropertyValue("padding-top"),10);return[t.clientX-s.left-o,t.clientY-s.top-n]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,o,n,a,h,l){if(!n)return;const c=i(e,t,s);return c[0]=Math.ceil((c[0]+(l?a/2:0))/a),c[1]=Math.ceil(c[1]/h),c[0]=Math.min(Math.max(c[0],1),r+(l?1:0)),c[1]=Math.min(Math.max(c[1],1),o),c}},9686(e,t){function i(e,t,i,o){const h=e-s(e,i),l=t-s(t,i),c=Math.abs(h-l)-function(e,t,i){let o=0;const n=e-s(e,i),a=t-s(t,i);for(let s=0;s=0&&et?"A":"B"}function o(e,t,i,s,r,o){let n=e,a=t,h="";for(;(n!==i||a!==s)&&a>=0&&ao.cols-1?(h+=o.buffer.translateBufferLineToString(a,!1,e,n),n=0,e=0,a++):!r&&n<0&&(h+=o.buffer.translateBufferLineToString(a,!1,0,e+1),n=o.cols-1,e=n,a--);return h+o.buffer.translateBufferLineToString(a,!1,e,n)}function n(e,t){return""+(t?"O":"[")+e}function a(e,t){e=Math.floor(e);let i="";for(let s=0;s0?h-s(h,l):t;const _=h,u=function(e,t,r,o,n,a){let h;return h=i(t,o,n,a).length>0?o-s(o,n):t,e=r&&he?"D":"C",a(Math.abs(l-e),n(d,h));d=c>t?"D":"C";const _=Math.abs(c-t);return a(function(e,t){return t.cols-e}(c>t?e:l,r)+(_-1)*r.cols+1+((c>t?l:e)-1),n(d,h))}},6081(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(f.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new _.ParserApi(this._core)}get unicode(){return this._checkProposedApi(),new u.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new d.BufferNamespaceApi(this._core))}get markers(){return this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.mouseStateService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return a.promptLabel.get()},set promptLabel(e){a.promptLabel.set(e)},get tooMuchOutput(){return a.tooMuchOutput.get()},set tooMuchOutput(e){a.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(p of e)if(p===1/0||isNaN(p)||p%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(p of e)if(p&&(p===1/0||isNaN(p)||p%1!=0||p<0))throw new Error("This API only accepts positive integers")}}t.Terminal=v},3955(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const o=i(1433),n=i(2744),a=i(9176),h=i(6181),l=i(2274),c=i(654),d=i(7098),_=i(4103),u=i(4812),f=i(6501),p=i(8636),v=i(4159);let g=1,m=class extends u.Disposable{constructor(e,t,i,s,r,a,d,_,f,m,b,w,y,C){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=d,this._charSizeService=f,this._optionsService=m,this._bufferService=b,this._coreService=w,this._coreBrowserService=y,this._themeService=C,this._terminalClass=g++,this._rowElements=[],this._selectionRenderModel=(0,l.createSelectionRenderModel)(),this._lastSelectionColumnMode=!1,this._rowHasBlinkingCells=[],this._rowHasBlinkingCellsCount=0,this._onRequestRedraw=this._register(new p.Emitter),this.onRequestRedraw=this._onRequestRedraw.event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(e=>this._injectCss(e))),this._injectCss(this._themeService.colors),this._rowFactory=_.createInstance(o.DomRendererRowFactory,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(e=>this._handleLinkHover(e))),this._register(this._linkifier2.onHideLinkUnderline(e=>this._handleLinkLeave(e))),this._cursorBlinkStateManager=new S(this._rowContainer,this._coreBrowserService),this._register((0,v.addDisposableListener)(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register((0,u.toDisposable)(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new c.TextBlinkStateManager(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register((0,u.toDisposable)(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new n.WidthCache,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${_.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${i} { color: ${s.css}; }${this._terminalSelector} .xterm-fg-${i}.xterm-dim { color: ${_.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .xterm-bg-${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR} { color: ${_.color.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${_.color.multiplyOpacity(_.color.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,i){const s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i);let r=0,o=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(r=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow));let n=0,a=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),this._selectionRenderModel.hasSelection){const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,o=this._selectionRenderModel.viewportCappedStartRow,h=this._selectionRenderModel.viewportCappedEndRow;n=o,a=h;const l=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];l.appendChild(this._createSelectionElement(o,i?t[0]:e[0],i?e[0]:t[0],h-o+1))}else{const i=s===o?e[0]:0,n=o===r?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(o,i,n));const a=h-o-1;if(l.appendChild(this._createSelectionElement(o+1,0,this._bufferService.cols,a)),o!==h){const e=r===h?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(h,0,e))}}this._selectionContainer.appendChild(l)}let h=Math.min(r,n),l=Math.max(o,a);if(l>=0){h=Math.max(h,0),l=Math.min(l,s-1);const e=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&e>=0&&ethis.dimensions.css.canvas.width&&(n=this.dimensions.css.canvas.width-o),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${o}px`,r.style.width=`${n}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),o=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,n=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle,h={hasBlinkingCells:!1};for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l];if(!t)continue;const c=i.lines.get(e);c?(t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,n,a,r,o,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,h)),this._setRowBlinkState(l,h.hasBlinkingCells)):(t.replaceChildren(),this._setRowBlinkState(l,!1))}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,o){i<0&&(e=0),s<0&&(t=0);const n=this._bufferService.rows-1;i=Math.max(Math.min(i,n),0),s=Math.max(Math.min(s,n),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,l=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle,u={hasBlinkingCells:!1};for(let n=i;n<=s;++n){const f=n+a.ydisp,p=this._rowElements[n];if(!p)continue;const v=a.lines.get(f);v?(p.replaceChildren(...this._rowFactory.createRow(v,f,f===h,d,_,l,c,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,o?n===i?e:0:-1,o?(n===s?t:r)-1:-1,u)),this._setRowBlinkState(n,u.hasBlinkingCells)):(p.replaceChildren(),this._setRowBlinkState(n,!1))}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};t.DomRenderer=m,t.DomRenderer=m=s([r(7,f.IInstantiationService),r(8,d.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,f.ICoreService),r(12,d.ICoreBrowserService),r(13,d.IThemeService)],m);class S{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){void 0!==this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}}},1433(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const o=i(9176),n=i(8938),a=i(3055),h=i(6501),l=i(4103),c=i(7098),d=i(945),_=i(6181),u=i(5451);let f=class{constructor(e,t,i,s,r,o,n){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=o,this._themeService=n,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,c,_,f,p,v,g){const m=[];g&&(g.hasBlinkingCells=!1);const S=this._characterJoinerService.getJoinedCharacters(t),b=this._themeService.colors;let w,y=e.getNoBgTrimmedLength();i&&y=A,F=I,W=this._workCell;if(S.length>0&&I===S[0][0]&&N){const s=S.shift(),r=this._isCellInSelection(s[0],t);for(C=s[0]+1;C=s[1],N?(H=!0,W=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),F=s[1]-1,y=W.getWidth()):A=s[1]}const z=this._isCellInSelection(I,t),K=i&&I===a,U=O&&I>=p&&I<=v;g&&W.isBlink()&&(g.hasBlinkingCells=!0),!c&&W.isBlink()&&P.push("xterm-blink-hidden");let j=!1;this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{j=!0});let $=W.getChars()||n.WHITESPACE_CELL_CHAR;if(" "===$&&(W.isUnderline()||W.isOverline())&&($=" "),k=y*_-f.get($,W.isBold(),W.isItalic()),w){if(D&&(z&&T||!z&&!T&&W.bg===L)&&(z&&T&&b.selectionForeground||W.fg===x)&&W.extended.ext===R&&U===M&&k===B&&!K&&!H&&!j&&N){W.isInvisible()?E+=n.WHITESPACE_CELL_CHAR:E+=$,D++;continue}D&&(w.textContent=E),w=this._document.createElement("span"),D=0,E=""}else w=this._document.createElement("span");if(L=W.bg,x=W.fg,R=W.extended.ext,M=U,B=k,T=z,H&&a>=I&&a<=F&&(a=I),!this._coreService.isCursorHidden&&K&&this._coreService.isCursorInitialized)if(P.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&P.push("xterm-cursor-blink"),P.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":P.push("xterm-cursor-outline");break;case"block":P.push("xterm-cursor-block");break;case"bar":P.push("xterm-cursor-bar");break;case"underline":P.push("xterm-cursor-underline")}if(W.isBold()&&P.push("xterm-bold"),W.isItalic()&&P.push("xterm-italic"),W.isDim()&&P.push("xterm-dim"),E=W.isInvisible()?n.WHITESPACE_CELL_CHAR:W.getChars()||n.WHITESPACE_CELL_CHAR,W.isUnderline()&&(P.push(`xterm-underline-${W.extended.underlineStyle}`)," "===E&&(E=" "),!W.isUnderlineColorDefault()))if(W.isUnderlineColorRGB())w.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(W.getUnderlineColor()).join(",")})`;else{let e=W.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&W.isBold()&&e<8&&(e+=8),w.style.textDecorationColor=b.ansi[e].css}W.isOverline()&&(P.push("xterm-overline")," "===E&&(E=" ")),W.isStrikethrough()&&P.push("xterm-strikethrough"),U&&(w.style.textDecoration="underline");let q=W.getFgColor(),V=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();const G=!!W.isInverse();if(G){const e=q;q=X,X=e;const t=V;V=Y,Y=t}let J,Z,Q,ee=!1;switch(this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{"top"!==e.options.layer&&ee||(e.backgroundColorRGB&&(Y=50331648,X=e.backgroundColorRGB.rgba>>8&16777215,J=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,q=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),ee="top"===e.options.layer)}),!ee&&z&&(J=this._coreBrowserService.isFocused?b.selectionBackgroundOpaque:b.selectionInactiveBackgroundOpaque,X=J.rgba>>8&16777215,Y=50331648,ee=!0,b.selectionForeground&&(V=50331648,q=b.selectionForeground.rgba>>8&16777215,Z=b.selectionForeground)),ee&&P.push("xterm-decoration-top"),Y){case 16777216:case 33554432:Q=b.ansi[X],P.push(`xterm-bg-${X}`);break;case 50331648:Q=l.channels.toColor(X>>16,X>>8&255,255&X),this._addStyle(w,`background-color:#${(X>>>0).toString(16).padStart(6,"0")}`);break;default:G?(Q=b.foreground,P.push(`xterm-bg-${o.INVERTED_DEFAULT_COLOR}`)):Q=b.background}switch(J||W.isDim()&&(J=l.color.multiplyOpacity(Q,.5)),V){case 16777216:case 33554432:W.isBold()&&q<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(q+=8),this._applyMinimumContrast(w,Q,b.ansi[q],W,J,void 0)||P.push(`xterm-fg-${q}`);break;case 50331648:const e=l.channels.toColor(q>>16&255,q>>8&255,255&q);this._applyMinimumContrast(w,Q,e,W,J,Z)||this._addStyle(w,`color:#${q.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(w,Q,b.foreground,W,J,Z)||G&&P.push(`xterm-fg-${o.INVERTED_DEFAULT_COLOR}`)}P.length&&(w.className=P.join(" "),P.length=0),K||H||j||!N?w.textContent=E:D++,k!==this.defaultSpacing&&(w.style.letterSpacing=`${k}px`),m.push(w),I=F}return w&&D&&(w.textContent=E),m}_applyMinimumContrast(e,t,i,s,r,o){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const n=this._getContrastCache(s);let a;if(r||o||(a=n.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=l.color.ensureContrastRatio(r??t,o??i,e),n.setColor((r??t).rgba,(o??i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};t.DomRendererRowFactory=f,t.DomRendererRowFactory=f=s([r(1,c.ICharacterJoinerService),r(2,h.IOptionsService),r(3,c.ICoreBrowserService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,c.IThemeService)],f)},2744(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0;const s=i(6181);t.WidthCache=class{constructor(e=()=>new r){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let o=this._holey.get(r);if(void 0===o){let s=0;t&&(s|=1),i&&(s|=2),o=this._measure(e,s),o>0&&this._holey.set(r,o)}return o}_measure(e,t){return this._canvasElements[t].measure(e)}};class r{constructor(){"undefined"!=typeof OffscreenCanvas?(this._canvas=new OffscreenCanvas(1,1),this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d")))}setFont(e,t,i,s){const r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}}},9176(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181(e,t){function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,o){return 1===t&&r>Math.ceil(1.5*o)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,o=t[1]-r,n=i[1]-r,a=Math.max(o,0),h=Math.min(n,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},654(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.TextBlinkStateManager=void 0;const s=i(4812);class r extends s.Disposable{constructor(e,t,i){super(),this._renderCallback=e,this._coreBrowserService=t,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",e=>{this.setIntervalDuration(e)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const e=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(e||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}t.TextBlinkStateManager=r},8501(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._domNodePointerDown(e)))}_createArrow(e){const t=this._register(new c.ScrollbarArrow(e));return this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode),t}_createSlider(e,t,i,s){this.slider=new h.FastDomNode(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(a.addDisposableListener(this.slider.domNode,a.eventType.POINTER_DOWN,e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))})),this._onclick(this.slider.domNode,e=>{e.leftButton&&e.stopPropagation()})}_handleElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._handlePointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._handlePointerDown(e)}_handlePointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=a.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{const r=this._sliderOrthogonalPointerPosition(e),o=Math.abs(r-i);if(u.isWindows&&o>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const n=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(n))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=f},1203(e,t){function i(e){return"number"==typeof e?`${e}px`:e}Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.FastDomNode=class{constructor(e){this.domNode=e,this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._className="",this._position="",this._layerHint=!1,this._contain="none"}setWidth(e){const t=i(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=i(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=i(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=i(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=i(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=i(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=e?"translate3d(0px, 0px, 0px)":"")}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}}},928(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{try{e.releasePointerCapture(t)}catch{}}))}catch{o=a.getWindow(e)}this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_MOVE,e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)})),this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_UP,e=>this.stopMonitoring(!0)))}}},9699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.horizontalScrollbarSize:0,2===t.horizontal?0:t.horizontalScrollbarSize,2===t.vertical?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"xterm-horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._handleElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(2===e.horizontal?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},3988(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;ni&&(s=i-t),s<0&&(s=0),r<0&&(r=0),n+r>o&&(n=o-r),n<0&&(n=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=o,this.scrollTop=n}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,o=this.height!==e.height,n=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:o,scrollHeightChanged:n,scrollTopChanged:a}}}t.ScrollState=o;class n extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0!==this._smoothScrollDuration){if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new l(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):l.start(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=l.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}else this.setScrollPositionNow(e)}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=n;class a{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}class l{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this._scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this._scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let n,a;return e0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),o=Math.abs(t.deltaX),n=Math.abs(t.deltaY),a=Math.max(Math.min(s,o),1),h=Math.max(Math.min(r,n),1),l=Math.max(s,o),c=Math.max(r,n);l%a===0&&c%h===0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}S.INSTANCE=new S;class b extends _.Widget{get options(){return this._options}constructor(e,t,i){let s;super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,t=t??{};const r=!i;i?s=i:(t.mouseWheelSmoothScroll=!1,s=new g.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)})),this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:1,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:1,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,v.isMac&&(t.className+=" xterm-mac"),t}(t),this._scrollable=s,this._register(this._scrollable.onScroll(e=>{this._handleScroll(e),this._onScroll.fire(e)})),r&&this._register(this._scrollable);const o={handleMouseWheel:e=>this._handleMouseWheel(e),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,o)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,o)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,e=>this._handleMouseOver(e)),this._onmouseleave(this._listenOnDomNode,e=>this._handleMouseLeave(e)),this._hideTimeout=this._register(new u.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,v.isMac&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalHasArrows&&(this._options.horizontalHasArrows=e.horizontalHasArrows),void 0!==e.verticalHasArrows&&(this._options.verticalHasArrows=e.verticalHasArrows),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new l.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._handleMouseWheel(new l.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.eventType.MOUSE_WHEEL,e,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const o=!v.isMac&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!o||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const n=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=n.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=n.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(n.scrollLeft!==a.scrollLeft||n.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" xterm-shadow-left":"",r=t?" xterm-shadow-top":"",o=i||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${r}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${o}${r}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet(()=>this._hide(),500)}}t.SmoothScrollableElement=b},9594(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._arrowPointerDown(e))),this._register(c.addStandardDisposableListener(this.domNode,c.eventType.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._pointerdownRepeatTimer=this._register(new c.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,c.getWindow(e))},200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault())}}t.ScrollbarArrow=d},1270(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,o){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setArrowSize(e){const t=Math.round(e);this._arrowSize!==t&&(this._arrowSize=t,this._refreshComputedValues())}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const o=Math.max(0,i-e),n=Math.max(0,o-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(n),computedSliderRatio:0,computedSliderPosition:0};const h=Math.round(Math.max(20,Math.floor(i*n/s))),l=(n-h)/(s-i),c=r*l;return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(h),computedSliderRatio:l,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" xterm-fade":"")))}}t.ScrollbarVisibilityController=o},2650(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{s||(s=!0,this._remove(i))}}_remove(e){if(e.prev!==_.Undefined&&e.next!==_.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===_.Undefined&&e.next===_.Undefined?(this._first=_.Undefined,this._last=_.Undefined):e.next===_.Undefined?(this._last=this._last.prev,this._last.next=_.Undefined):e.prev===_.Undefined&&(this._first=this._first.next,this._first.prev=_.Undefined)}*[Symbol.iterator](){let e=this._first;for(;e!==_.Undefined;)yield e.element,e=e.next}}var f;!function(e){e.TAP="-xterm-gesturetap",e.CHANGE="-xterm-gesturechange",e.START="-xterm-gesturestart",e.END="-xterm-gesturesend",e.CONTEXT_MENU="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends l.Disposable{constructor(){super(),this._dispatched=!1,this._targets=new u,this._ignoreTargets=new u,this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;const e=c;this._register(h.addDisposableListener(e.document,"touchstart",e=>this._handleTouchStart(e),{passive:!1})),this._register(h.addDisposableListener(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(h.addDisposableListener(e.document,"touchmove",e=>this._handleTouchMove(e),{passive:!1}))}static addTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._targets.push(e);return(0,l.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._ignoreTargets.push(e);return(0,l.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in c||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){const t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let i=0,s=e.targetTouches.length;i=p._holdDelay&&Math.abs(n.initialPageX-d(n.rollingPageX))<30&&Math.abs(n.initialPageY-d(n.rollingPageY))<30){const e=this._newGestureEvent(f.CONTEXT_MENU,n.initialTarget);e.pageX=d(n.rollingPageX),e.pageY=d(n.rollingPageY),this._dispatchEvent(e)}else if(1===s){const t=d(n.rollingPageX),s=d(n.rollingPageY),r=d(n.rollingTimestamps)-n.rollingTimestamps[0],o=t-n.rollingPageX[0],a=s-n.rollingPageY[0],h=[...this._targets].filter(e=>n.initialTarget instanceof Node&&e.contains(n.initialTarget));this._inertia(e,h,i,Math.abs(o)/r,o>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this._dispatchEvent(this._newGestureEvent(f.END,n.initialTarget)),delete this._activeTouches[o.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}_dispatchEvent(e){if(e.type===f.TAP){const t=(new Date).getTime();let i;i=t-this._lastSetTapCountTime>p._clearTapCountTime?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.CHANGE&&e.type!==f.CONTEXT_MENU||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this._ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this._targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort((e,t)=>e[0]-t[0]);for(const[,i]of t)i.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,i,s,r,o,n,a,l){this._handle=h.scheduleAtNextAnimationFrame(e,()=>{const h=Date.now(),c=h-i;let d=0,_=0,u=!0;s+=p._scrollFriction*c,n+=p._scrollFriction*c,s>0&&(u=!1,d=r*s*c),n>0&&(u=!1,_=a*n*c);const v=this._newGestureEvent(f.CHANGE);v.translationX=d,v.translationY=_,t.forEach(e=>e.dispatchEvent(v)),u||this._inertia(e,t,h,s,r,o+d,n,a,l+_)})}_handleTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}}t.Gesture=p,p._scrollFriction=-.005,p._holdDelay=700,p._clearTapCountTime=400,n([function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r||!s)throw new Error("not supported");const o=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(o)||Object.defineProperty(this,o,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[o]}}],p,"isTouchDevice",null)},8997(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition(),n=t.verticalHasArrows;super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(n?t.verticalScrollbarSize:0,2===t.vertical?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"xterm-vertical",scrollable:e,scrollByPage:t.scrollByPage}),this._arrowScrollDelta=0,this._setArrows(n,t.verticalScrollbarSize),this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._handleElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}_arrowScroll(e){const t=this._scrollable.getCurrentScrollPosition();this._scrollable.setScrollPositionNow({scrollTop:t.scrollTop+e})}_setArrows(e,t){if(this._arrowScrollDelta=t,!this._arrowUp||!this._arrowDown){const e=0;this._arrowUp=this._createArrow({className:"xterm-scra xterm-arrow-up",top:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})}if(this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;const i=e?"":"none";this._arrowUp.bgDomNode.style.display=i,this._arrowUp.domNode.style.display=i,this._arrowDown.bgDomNode.style.display=i,this._arrowDown.domNode.style.display=i}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){const t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},7741(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nt(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseover(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_OVER,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseleave(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_LEAVE,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}}t.Widget=c},5959(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):!!(this.selectionStart&&this.selectionStart[1]<0)&&(this.selectionStart=[0,0],!0)}}},4792(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const o=i(6501),n=i(4812),a=i(8636);let h=class extends n.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h,t.CharSizeService=h=s([r(2,o.IOptionsService)],h);class l extends n.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends l{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends l{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},o=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(5451),a=i(8938),h=i(3055),l=i(6501);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new h.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;tthis._screenDprMonitor.setWindow(e))),this._register(s.EventUtils.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",()=>this._isFocused=!0)),this._register((0,r.addDisposableListener)(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}}t.CoreBrowserService=n;class a extends o.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new o.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,o.toDisposable)(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},2136(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.KeyboardService=void 0;const o=i(706),n=i(7241),a=i(9249),h=i(701),l=i(6501);let c=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??=new a.Win32InputMode,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new n.KittyKeyboard,this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);const t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):(0,o.evaluateKeyboardEvent)(e,this._coreService.decPrivateModes.applicationCursorKeys,h.isMac,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);const t=this._coreService.kittyKeyboard.flags;return this.useKitty&&2&t?this._getKittyKeyboard().evaluate(e,t,3,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):void 0}get useKitty(){const e=this._coreService.kittyKeyboard.flags;return!(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard||!n.KittyKeyboard.shouldUseProtocol(e))}get useWin32InputMode(){return!(!this._optionsService.rawOptions.vtExtensions?.win32InputMode||!this._coreService.decPrivateModes.win32InputMode)}};t.KeyboardService=c,t.KeyboardService=c=s([r(0,l.ICoreService),r(1,l.IOptionsService)],c)},9820(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(4812);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8294(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseCoordsService=void 0;const o=i(4159),n=i(5251),a=i(7098);let h=class{constructor(e,t){this._charSizeService=e,this._renderService=t}getCoords(e,t,i,s,r){return(0,n.getCoords)((0,o.getWindow)(t),e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,n.getCoordsRelativeToElement)((0,o.getWindow)(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseCoordsService=h,t.MouseCoordsService=h=s([r(0,a.ICharSizeService),r(1,a.IRenderService)],h)},9784(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AltMouseCursorController=t.MouseService=void 0;const o=i(4159),n=i(6501),a=i(4812),h=i(7098),l=i(2650);let c=class{constructor(e,t,i,s,r,o,n,a,h){this._renderService=e,this._mouseCoordsService=t,this._mouseStateService=i,this._coreService=s,this._bufferService=r,this._optionsService=o,this._selectionService=n,this._logService=a,this._coreBrowserService=h,this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}bindMouse(e,t,i){const{element:s,document:r}=e,n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},h={target:e,focus:i,requestedEvents:n},c={mouseup:e=>this._handleMouseUp(h,e),wheel:e=>this._handleWheel(h,e),mousedrag:e=>this._handleMouseDrag(h,e),mousemove:e=>this._handleMouseMove(h,e)};this._altMouseCursor=new d(s,r,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),t(this._altMouseCursor),t(this._mouseStateService.onProtocolChange(e=>{this._handleProtocolChange(h,c,e)})),t(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(s),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,t((0,a.toDisposable)(()=>{n.mouseup&&r.removeEventListener("mouseup",n.mouseup),n.mousedrag&&r.removeEventListener("mousemove",n.mousedrag)})),t((0,o.addDisposableListener)(s,"mousedown",e=>this._handleMouseDown(h,e))),t((0,o.addDisposableListener)(s,"wheel",e=>this._handlePassiveWheel(h,e),{passive:!1})),t(l.Gesture.addTarget(e.screenElement)),t((0,o.addDisposableListener)(e.screenElement,l.EventType.START,()=>this._handleTouchStart())),t((0,o.addDisposableListener)(e.screenElement,l.EventType.CHANGE,e=>this._handleTouchChange(h,e)))}_sendEvent(e,t){const i=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;const e=t.deltaY;if(0===e)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return!1;r=e<0?0:1,s=4;break;default:return!1}if(void 0===r||void 0===s||s>4)return!1;if(4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!t.altKey)return!1;const o=4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:!o&&t.altKey,shift:t.shiftKey})}_handleMouseUp(e,t){this._sendEvent(e,t),t.buttons||(e.requestedEvents.mouseup&&e.target.document.removeEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.removeEventListener("mousemove",e.requestedEvents.mousedrag))}_handleWheel(e,t){return this._sendEvent(e,t),t.preventDefault(),t.stopPropagation(),!1}_handleMouseDrag(e,t){t.buttons&&this._sendEvent(e,t)}_handleMouseMove(e,t){t.buttons||this._sendEvent(e,t)}_handleMouseDown(e,t){t.preventDefault(),e.focus(),this._mouseStateService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(t)&&(this._sendEvent(e,t),e.requestedEvents.mouseup&&e.target.document.addEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.addEventListener("mousemove",e.requestedEvents.mousedrag))}_handlePassiveWheel(e,t){if(!e.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;if(!this._bufferService.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return t.preventDefault(),t.stopPropagation(),!1;const e=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(e,!0),t.preventDefault(),t.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(e,t){t.preventDefault(),t.stopPropagation(),e.requestedEvents.wheel?this._handleTouchScrollAsWheel(e,t):this._bufferService.buffer.hasScrollback?e.target.handleTouchScroll?.(t.translationY):this._handleTouchScrollAsKeys(t)}_handleTouchScrollAsKeys(e){const t=this._renderService?.dimensions.css.cell.height;if(!t)return;this._touchScrollAccumulator-=e.translationY;const i=Math.trunc(this._touchScrollAccumulator/t);if(0===i)return;this._touchScrollAccumulator-=i*t;const s=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(i<0?"A":"B");for(let e=0;e0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._mouseStateService.isPixelEncoding))return!1;if(!this._mouseStateService.restrictMouseEvent(e))return!1;const t=this._mouseStateService.encodeMouseEvent(e);return t&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}_explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.MouseService=c,t.MouseService=c=s([r(0,h.IRenderService),r(1,h.IMouseCoordsService),r(2,n.IMouseStateService),r(3,n.ICoreService),r(4,n.IBufferService),r(5,n.IOptionsService),r(6,h.ISelectionService),r(7,n.ILogService),r(8,h.ICoreBrowserService)],c);class d{constructor(e,t,i){this._element=e,this._document=t,this._isActive=i,this._listeners=new a.MutableDisposable}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;const e=new a.DisposableStore,t=e=>this.syncFromModifier(e);e.add((0,o.addDisposableListener)(this._document,"keydown",t)),e.add((0,o.addDisposableListener)(this._document,"keyup",t)),e.add((0,o.addDisposableListener)(this._element,"mousemove",t));const i=this._element.ownerDocument?.defaultView;i&&e.add((0,o.addDisposableListener)(i,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=e}resetClass(){this._updateClass(!1)}syncFromModifier(e){this._isActive()&&this._updateClass(e.getModifierState("Alt"))}_updateClass(e){e?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}}t.AltMouseCursorController=d},5783(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const o=i(4852),n=i(7098),a=i(4812),h=i(6168),l=i(6501),c=i(8636);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,n,l,d,u,f){super(),this._rowCount=e,this._optionsService=i,this._logService=s,this._charSizeService=r,this._coreService=n,this._coreBrowserService=u,this._renderer=this._register(new a.MutableDisposable),this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._pausedResizeTask=this._register(new h.DebouncedIdleTask(this._logService)),this._renderDebouncer=new o.RenderDebouncer((e,t)=>this._renderRows(e,t),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new _(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register((0,a.toDisposable)(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(d.onResize(()=>this._fullRefresh())),this._register(d.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(d.cols,d.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(d.buffer.y,d.buffer.y,void 0,!0))),this._register(f.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(e=>this._registerIntersectionObserver(e,t)))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver(e=>this._handleIntersectionChange(e[e.length-1]),{threshold:0});this._observerDisposable.value=(0,a.toDisposable)(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=i,i.observe(t)}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(e=>this.refreshRows(e.start,e.end,e.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,l.IOptionsService),r(3,l.ILogService),r(4,n.ICharSizeService),r(5,l.ICoreService),r(6,l.IDecorationService),r(7,l.IBufferService),r(8,n.ICoreBrowserService),r(9,n.IThemeService)],d);class _{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(e=>this._handleTrim(e)),this._register(this._bufferService.buffers.onBufferActivate(e=>this._handleBufferActivate(e))),this.enable(),this._model=new d.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,u.toDisposable)(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(e=>{e.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(b," ")).join(f.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),f.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:f.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(2===e.button&&this.hasSelection||0!==e.button||this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){const t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);const i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return(!this._optionsService.rawOptions.mouseEventsRequireAlt||!this._mouseStateService.areMouseEventsActive)&&e.altKey&&!(f.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,c.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(e=>this._handleTrim(e))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,o=r.lines.get(e[1]);if(!o)return;const n=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(o,e[0]),h=a;const l=e[0]-a;let c=0,d=0,_=0,u=0;if(" "===n.charAt(a)){for(;a>0&&" "===n.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(o.loadCell(t-1,this._workCell));){o.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+l-c+_,p=Math.min(this._bufferService.cols,h-a+c+d-_-u);if(t||""!==n.slice(a,h).trim()){if(i&&0===f&&32!==o.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&o.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==o.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=w,t.SelectionService=w=n([h(3,g.IBufferService),h(4,g.ICoreService),h(5,_.IMouseCoordsService),h(6,g.IOptionsService),h(7,g.IMouseStateService),h(8,_.IRenderService),h(9,_.ICoreBrowserService)],w)},7098(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IKeyboardService=t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.IMouseCoordsService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseCoordsService=(0,s.createDecorator)("MouseCoordsService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService"),t.IKeyboardService=(0,s.createDecorator)("KeyboardService")},9078(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const o=i(7174),n=i(9302),a=i(4103),h=i(4812),l=i(6501),c=i(8636),d=a.css.toColor("#ffffff"),_=a.css.toColor("#000000"),u=a.css.toColor("#ffffff"),f=_,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},v=d;let g=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new o.ColorContrastCache,this._halfContrastCache=new o.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:_,cursor:u,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(_,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(_,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:n.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}_setTheme(e={}){const t=this._colors;if(t.foreground=m(e.foreground,d),t.background=m(e.background,_),t.cursor=a.color.blend(t.background,m(e.cursor,u)),t.cursorAccent=a.color.blend(t.background,m(e.cursorAccent,f)),t.selectionBackgroundTransparent=m(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=m(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?m(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=m(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=m(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=m(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=m(e.overviewRulerBorder,v),t.ansi=n.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=m(e.black,n.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=m(e.red,n.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=m(e.green,n.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=m(e.yellow,n.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=m(e.blue,n.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=m(e.magenta,n.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=m(e.cyan,n.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=m(e.white,n.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=m(e.brightBlack,n.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=m(e.brightRed,n.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=m(e.brightGreen,n.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=m(e.brightYellow,n.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=m(e.brightBlue,n.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=m(e.brightMagenta,n.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=m(e.brightCyan,n.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=m(e.brightWhite,n.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;ssetTimeout(t,e))},t.disposableTimeout=function(e,t=0,i){const r=setTimeout(()=>{e(),i&&o.dispose()},t),o=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(o),o};const s=i(4812);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{e()},t);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},5639(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(4812),r=i(8636);class o extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(n||(t.channels=n={})),function(e){function t(e,t){return o=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:n.toCss(i,s,r,o),rgba:n.toRgba(i,s,r,o)}}e.blend=function(e,t){if(o=(255&t.rgba)/255,1===o)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,l=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),{css:n.toCss(i,s,r),rgba:n.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return n.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:n.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return o=255&e.rgba,t(e,o*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o=parseInt(e.slice(4,5).repeat(2),16),n.toColor(i,s,r,o);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1],10),s=parseInt(h[2],10),r=parseInt(h[3],10),o=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),n.toColor(i,s,r,o);if("transparent"===e)return{css:"transparent",rgba:0};if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,o]=t.getImageData(0,0,1,1).data,255!==o)throw new Error("css.toColor: Unsupported css format");return{rgba:n.toRgba(i,s,r,o),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,o=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(l||(t.rgb=l={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c0||a>0||h>0);)n-=Math.max(0,Math.ceil(.1*n)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));return(n<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c>>0}e.blend=function(e,t){if(o=(255&t)/255,1===o)return t;const a=t>>24&255,h=t>>16&255,l=t>>8&255,c=e>>24&255,d=e>>16&255,_=e>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),n.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=l.relativeLuminance(e>>8),o=l.relativeLuminance(i>>8);if(_(r,o)>8));if(n_(r,l.relativeLuminance(t>>8))?o:t}return o}const n=a(e,i,s),h=_(r,l.relativeLuminance(n>>8));if(h_(r,l.relativeLuminance(o>>8))?n:o}return n}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),o=i(7276),n=i(9640),a=i(56),h=i(4071),l=i(6478),c=i(7428),d=i(6415),_=i(5746),u=i(5882),f=i(2486),p=i(3562),v=i(8811),g=i(8636),m=i(4812);let S=!1;class b extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new g.Emitter),this.onRender=this._onRender.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(n.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(l.MouseStateService)),this._instantiationService.setService(s.IMouseStateService,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(d.UnicodeService)),this.unicodeService.register(new c.UnicodeV6),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(_.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(v.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new f.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(g.EventUtils.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(g.EventUtils.forward(this._bufferService.onResize,this._onResize)),this._register(g.EventUtils.forward(this.coreService.onData,this._onData)),this._register(g.EventUtils.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new p.WriteBuffer((e,t)=>this._inputHandler.parse(e,t))),this._register(g.EventUtils.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!S&&(this._logService.warn("writeSync is unreliable and will be removed soon."),S=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.backend&&void 0!==t.buildNumber&&(e=!!("conpty"===t.backend&&t.buildNumber<21376)),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)(()=>{for(const t of e)t.dispose()})}}}t.CoreTerminal=b},8636(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const s=i(4812);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners.push(r);const o=(0,s.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&this._listeners.splice(e,1)});return i&&(Array.isArray(i)?i.push(o):i.add(o)),o}),this._event}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:t,thisArgs:i}=this._listeners[0];return void t.call(i,e)}default:{const t=this._listeners.slice();for(const{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(e){e.forward=function(e,t){return e(e=>t.fire(e))},e.map=function(e,t){return(i,s,r)=>e(e=>i.call(s,t(e)),void 0,r)},e.any=function(...e){return(t,i,r)=>{const o=new s.DisposableStore;for(const s of e)o.add(s(e=>t.call(i,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(r||(t.EventUtils=r={}))},2486(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=L;const o=i(6760),n=i(6717),a=i(4812),h=i(726),l=i(6107),c=i(8938),d=i(3055),_=i(5451),u=i(6501),f=i(6415),p=i(1346),v=i(9823),g=i(2607),m=i(8693),S=i(8636),b=i(7804),w={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var C;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(C||(t.WindowsOptionsReportType=C={}));let k=0;class D extends a.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,a,c,d,_=new n.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=a,this._mouseStateService=c,this._unicodeService=d,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new S.Emitter),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new E(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(e=>this._activeBuffer=e.activeBuffer)),this._parser.setCsiHandlerFallback((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})}),this._parser.setEscHandlerFallback(e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})}),this._parser.setExecuteHandlerFallback(e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})}),this._parser.setOscHandlerFallback((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})}),this._parser.setDcsHandlerFallback((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setApcHandlerFallback((e,t,i)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setPrintHandler((e,t,i)=>this.print(e,t,i)),this._parser.registerCsiHandler({final:"@"},e=>this.insertChars(e)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},e=>this.scrollLeft(e)),this._parser.registerCsiHandler({final:"A"},e=>this.cursorUp(e)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},e=>this.scrollRight(e)),this._parser.registerCsiHandler({final:"B"},e=>this.cursorDown(e)),this._parser.registerCsiHandler({final:"C"},e=>this.cursorForward(e)),this._parser.registerCsiHandler({final:"D"},e=>this.cursorBackward(e)),this._parser.registerCsiHandler({final:"E"},e=>this.cursorNextLine(e)),this._parser.registerCsiHandler({final:"F"},e=>this.cursorPrecedingLine(e)),this._parser.registerCsiHandler({final:"G"},e=>this.cursorCharAbsolute(e)),this._parser.registerCsiHandler({final:"H"},e=>this.cursorPosition(e)),this._parser.registerCsiHandler({final:"I"},e=>this.cursorForwardTab(e)),this._parser.registerCsiHandler({final:"J"},e=>this.eraseInDisplay(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},e=>this.eraseInDisplay(e,!0)),this._parser.registerCsiHandler({final:"K"},e=>this.eraseInLine(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},e=>this.eraseInLine(e,!0)),this._parser.registerCsiHandler({final:"L"},e=>this.insertLines(e)),this._parser.registerCsiHandler({final:"M"},e=>this.deleteLines(e)),this._parser.registerCsiHandler({final:"P"},e=>this.deleteChars(e)),this._parser.registerCsiHandler({final:"S"},e=>this.scrollUp(e)),this._parser.registerCsiHandler({final:"T"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"X"},e=>this.eraseChars(e)),this._parser.registerCsiHandler({final:"Z"},e=>this.cursorBackwardTab(e)),this._parser.registerCsiHandler({final:"^"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"`"},e=>this.charPosAbsolute(e)),this._parser.registerCsiHandler({final:"a"},e=>this.hPositionRelative(e)),this._parser.registerCsiHandler({final:"b"},e=>this.repeatPrecedingCharacter(e)),this._parser.registerCsiHandler({final:"c"},e=>this.sendDeviceAttributesPrimary(e)),this._parser.registerCsiHandler({prefix:">",final:"c"},e=>this.sendDeviceAttributesSecondary(e)),this._parser.registerCsiHandler({final:"d"},e=>this.linePosAbsolute(e)),this._parser.registerCsiHandler({final:"e"},e=>this.vPositionRelative(e)),this._parser.registerCsiHandler({final:"f"},e=>this.hVPosition(e)),this._parser.registerCsiHandler({final:"g"},e=>this.tabClear(e)),this._parser.registerCsiHandler({final:"h"},e=>this.setMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"h"},e=>this.setModePrivate(e)),this._parser.registerCsiHandler({final:"l"},e=>this.resetMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"l"},e=>this.resetModePrivate(e)),this._parser.registerCsiHandler({final:"m"},e=>this.charAttributes(e)),this._parser.registerCsiHandler({final:"n"},e=>this.deviceStatus(e)),this._parser.registerCsiHandler({prefix:"?",final:"n"},e=>this.deviceStatusPrivate(e)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},e=>this.softReset(e)),this._parser.registerCsiHandler({prefix:">",final:"q"},e=>this.sendXtVersion(e)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},e=>this.setCursorStyle(e)),this._parser.registerCsiHandler({final:"r"},e=>this.setScrollRegion(e)),this._parser.registerCsiHandler({final:"s"},e=>this.saveCursor(e)),this._parser.registerCsiHandler({final:"t"},e=>this.windowOptions(e)),this._parser.registerCsiHandler({final:"u"},e=>this.restoreCursor(e)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},e=>this.insertColumns(e)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},e=>this.deleteColumns(e)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},e=>this.selectProtected(e)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},e=>this.requestMode(e,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},e=>this.requestMode(e,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},e=>this.kittyKeyboardSet(e)),this._parser.registerCsiHandler({prefix:"?",final:"u"},e=>this.kittyKeyboardQuery(e)),this._parser.registerCsiHandler({prefix:">",final:"u"},e=>this.kittyKeyboardPush(e)),this._parser.registerCsiHandler({prefix:"<",final:"u"},e=>this.kittyKeyboardPop(e)),this._parser.setExecuteHandler("",()=>this.bell()),this._parser.setExecuteHandler("\n",()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler("\t",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("„",()=>this.index()),this._parser.setExecuteHandler("…",()=>this.nextLine()),this._parser.setExecuteHandler("ˆ",()=>this.tabSet()),this._parser.registerOscHandler(0,new p.OscHandler(e=>(this.setTitle(e),this.setIconName(e),!0))),this._parser.registerOscHandler(1,new p.OscHandler(e=>this.setIconName(e))),this._parser.registerOscHandler(2,new p.OscHandler(e=>this.setTitle(e))),this._parser.registerOscHandler(4,new p.OscHandler(e=>this.setOrReportIndexedColor(e))),this._parser.registerOscHandler(8,new p.OscHandler(e=>this.setHyperlink(e))),this._parser.registerOscHandler(10,new p.OscHandler(e=>this.setOrReportFgColor(e))),this._parser.registerOscHandler(11,new p.OscHandler(e=>this.setOrReportBgColor(e))),this._parser.registerOscHandler(12,new p.OscHandler(e=>this.setOrReportCursorColor(e))),this._parser.registerOscHandler(104,new p.OscHandler(e=>this.restoreIndexedColor(e))),this._parser.registerOscHandler(110,new p.OscHandler(e=>this.restoreFgColor(e))),this._parser.registerOscHandler(111,new p.OscHandler(e=>this.restoreBgColor(e))),this._parser.registerOscHandler(112,new p.OscHandler(e=>this.restoreCursorColor(e))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},()=>this.selectCharset("("+e)),this._parser.registerEscHandler({intermediates:")",final:e},()=>this.selectCharset(")"+e)),this._parser.registerEscHandler({intermediates:"*",final:e},()=>this.selectCharset("*"+e)),this._parser.registerEscHandler({intermediates:"+",final:e},()=>this.selectCharset("+"+e)),this._parser.registerEscHandler({intermediates:"-",final:e},()=>this.selectCharset("-"+e)),this._parser.registerEscHandler({intermediates:".",final:e},()=>this.selectCharset("."+e)),this._parser.registerEscHandler({intermediates:"/",final:e},()=>this.selectCharset("/"+e));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(e=>(this._logService.error("Parsing error: ",e),e)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new v.DcsHandler((e,t)=>this.requestStatusString(e,t)))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=u.LogLevelEnum.WARN){let t;const i=new Promise((e,i)=>{t=setTimeout(()=>i("#SLOW_TIMEOUT"),5e3)});Promise.race([e,i]).then(()=>{void 0!==t&&clearTimeout(t)},e=>{if(void 0!==t&&clearTimeout(t),"#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,o=0;const n=this._parseStack.paused;if(n){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(o=this._parseStack.position+131072)}if(this._logService.logLevel<=u.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,e=>String.fromCharCode(e)).join("")}"`)),this._logService.logLevel===u.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map(e=>e.charCodeAt(0)):e),this._parseBuffer.length131072)for(let t=o;t0&&2===p.getWidth(this._activeBuffer.x-1)&&p.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(d){const e=p;let t=this._activeBuffer.x-m;if(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),p=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!p)return;for(m>0&&p instanceof l.BufferLine&&p.copyCellsFrom(e,t,0,m,!1);t=0;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(_&&(p.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===p.getWidth(a-1)&&p.setCellFromCodepoint(a-1,c.NULL_CELL_CODE,c.NULL_CELL_WIDTH,u)),p.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===p.getWidth(this._activeBuffer.x)&&!p.hasContent(this._activeBuffer.x)&&p.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new v.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new g.ApcHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const o=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);o&&(o.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(o.isWrapped=!1))}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols){const e=this._activeBuffer.lines.get(i+1);e&&(e.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`P>|xterm.js(${b.XTERM_VERSION})\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(o.triggerDataEvent(`[${t?"":"?"}${e};${i}$y`),!0),_=e=>e?1:2,u=e.params[0];return t?d(u,2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(c.convertEol):0):1===u?d(u,_(i.applicationCursorKeys)):3===u?d(u,c.windowOptions.setWinLines?80===a?2:132===a?1:0:0):6===u?d(u,_(i.origin)):7===u?d(u,_(i.wraparound)):8===u?d(u,3):9===u?d(u,_("X10"===s)):12===u?d(u,_(c.cursorBlink)):25===u?d(u,_(!o.isCursorHidden)):45===u?d(u,_(i.reverseWraparound)):66===u?d(u,_(i.applicationKeypad)):67===u?d(u,4):1e3===u?d(u,_("VT200"===s)):1002===u?d(u,_("DRAG"===s)):1003===u?d(u,_("ANY"===s)):1004===u?d(u,_(i.sendFocus)):1005===u?d(u,4):1006===u?d(u,_("SGR"===r)):1015===u?d(u,4):1016===u?d(u,_("SGR_PIXELS"===r)):1048===u?d(u,1):47===u||1047===u||1049===u?d(u,_(h===l)):2004===u?d(u,_(i.bracketedPasteMode)):2026===u?d(u,_(i.synchronizedOutput)):9001===u&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?d(u,_(i.win32InputMode)):d(u,0)}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-67108864,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,o=0;do{if(s[o+r]=e.params[t+o],e.hasSubParams(t+o)){const i=e.getSubParams(t+o);let n=0;do{5===s[1]&&(r=1),s[o+n+1+r]=i[n]}while(++n=2||2===s[1]&&o+r>=5)break;s[1]&&(r=1)}while(++o+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-67108864,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-67108864,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-67108864,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-67108864,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:221===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.fg&=-134217729:222===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.bg&=-134217729:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("");break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[${e};${t}R`)}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[?${e};${t}R`);break;case 15:case 25:case 26:case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??1)&&this._onRequestColorSchemeQuery.fire()}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(C.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(C.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let e=0;e1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e,10);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex(e=>e.startsWith("id="));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new d.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${e}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let e=0;e0;e++)i.flags=s.pop();return 0===s.length&&t>0&&(i.flags=0),!0}}t.InputHandler=D;let E=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(k=e,e=t,t=k),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}E=s([r(0,u.IBufferService)],E)},4812(e,t){function i(e){return{dispose:e}}function s(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=s,t.combinedDisposable=function(...e){return i(()=>s(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},7710(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,o){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,o)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isMac=t.isSafari=t.isLegacyEdge=t.isChrome=t.isFirefox=t.isNode=void 0,t.getZoomFactor=function(e){return 1},t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1],10)},t.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isChrome=i.includes("Chrome"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e,t){this._getKey=e,this._array=[],this._insertedValues=[],this._isFlushingInserted=!1,this._deletedIndices=[],this._isFlushingDeleted=!1,this._flushInsertedTask=new s.IdleTaskQueue(t),this._flushDeletedTask=new s.IdleTaskQueue(t)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort((e,t)=>this._getKey(e)-this._getKey(t));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(r),!0}while(++re-t);let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},4220(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.LimitedStringBuilder=t.StringBuilder=void 0;class i{constructor(){this._chunks=[],this._length=0}get length(){return this._length}reset(){this._chunks.length=0,this._length=0}append(e){this._chunks.push(e),this._length+=e.length}toString(){return this._chunks.join("")}}t.StringBuilder=i,t.LimitedStringBuilder=class{constructor(e){this._limit=e,this._builder=new i}get length(){return this._builder.length}get limit(){return this._limit}reset(){this._builder.reset()}append(e){return this._builder.append(e),this._builder.length>this._limit&&(this._builder.reset(),!0)}toString(){return this._builder.toString()}}},6168(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;class i{constructor(e){this._tasks=[],this._i=0,this._logService=e}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ii)return r-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=s,t.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:s,t.DebouncedIdleTask=class{constructor(e){this._queue=new t.IdleTaskQueue(e)}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},7804(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.XTERM_VERSION=void 0,t.XTERM_VERSION="6.1.0-beta.287"},5882(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(4812),o=i(6168),n=i(5451),a=i(6107),h=i(3326),l=i(732),c=i(3055),d=i(8938),_=i(8158),u=i(6760);t.MAX_BUFFER_SIZE=4294967295;class f extends r.Disposable{constructor(e,t,i,n){super(),this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this._logService=n,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=a.DEFAULT_ATTR_DATA.clone(),this.savedCharset=u.DEFAULT_CHARSET,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=c.CellData.fromCharData([0,d.NULL_CELL_CHAR,d.NULL_CELL_WIDTH,d.NULL_CELL_CODE]),this._whitespaceCell=c.CellData.fromCharData([0,d.WHITESPACE_CELL_CHAR,d.WHITESPACE_CELL_WIDTH,d.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new o.IdleTaskQueue(this._logService),this._register((0,r.toDisposable)(()=>this._memoryCleanupQueue.clear())),this._register((0,r.toDisposable)(()=>this.clearAllMarkers())),this._stringCache=this._register(new h.BufferLineStringCache)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new a.BufferLine(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){e??=a.DEFAULT_ATTR_DATA;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(a.DEFAULT_ATTR_DATA);this._stringCache.clear();let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+o+1?(this.ybase--,o++,this.ydisp>0&&this.ydisp--):this.lines.push(new a.BufferLine(this._stringCache,e,i,!1)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),o&&(this.y+=o),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t0){const e=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,e)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,l.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(a.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,l.reflowLargerCreateNewLayout)(this.lines,s);(0,l.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(a.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=n&&e0&&(r.push({start:n+c.length+o,newLines:p}),o+=p.length),c.push(...p);let v=_.length-1,g=_[v];0===g&&(v--,g=_[v]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[v])break;if(c[v].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(v--,g=_[v]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,l.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;l--)if(a&&a.start>s+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(l--,a.newLines[e]);l++,e.push({index:s+1,amount:a.newLines.length}),h+=a.newLines.length,a=r[++n]}else this.lines.set(l,t[s--]);let l=0;for(let t=e.length-1;t>=0;t--)e[t].index+=l,this.lines.onInsertEmitter.fire(e[t]),l+=e[t].amount;const c=Math.max(0,i+o-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(e=>{t.line>=e.index&&(t.line+=e.amount)})),t.register(this.lines.onDelete(e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}t.Buffer=f},6107(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),o=i(8938),n=i(726),a=i(4220);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let h=0;const l=new r.CellData,c=new a.StringBuilder;class d{constructor(e,t,i,s=!1){this._stringCache=e,this.isWrapped=s,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const n=i??r.CellData.fromCharData([0,o.NULL_CELL_CHAR,o.NULL_CELL_WIDTH,o.NULL_CELL_CODE]);for(let e=0;e>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._invalidateStringCache(),this._data[3*e+1]=t[o.CHAR_DATA_ATTR_INDEX],t[o.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[o.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[o.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[o.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,n.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,i){return h=3*e,i.content=this._data[h+0],i.fg=this._data[h+1],i.bg=this._data[h+2],2097152&i.content?i.combinedData=this._combined[e]:i.combinedData="",268435456&i.bg?i.extended=this._extendedAttrs[e]:i.extended=t.DEFAULT_ATTR_DATA.extended.clone(),i}setCell(e,t){this._invalidateStringCache(),2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){this._invalidateStringCache(),268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){this._invalidateStringCache();let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,n.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,n.stringFromCodePoint)(2097151&s)+(0,n.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if(this._invalidateStringCache(),(e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,l));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){this._invalidateStringCache();const o=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=o[3*(t+r)+e];this._copyCellMapsFrom(e,t+r,i+r)}else for(let r=0;r>22||1}s&&s.push(t);const h=c.toString();if(c.reset(),r){const t=this._getStringCacheEntry(!0);t.value=h,t.isTrimmed=!!e}return h}_getStringCacheEntry(e){const t=this._stringCacheEntryRef?.deref();if(t&&t.generation===this._stringCache.generation)return t;if(!e)return;const i=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(i),i}_invalidateStringCache(){const e=this._getStringCacheEntry(!1);e&&(e.value=void 0,e.isTrimmed=!1)}_copyCellMapsFrom(e,t,i){const s=3*t;2097152&e._data[s+0]&&(this._combined[i]=e._combined[t]),268435456&e._data[s+2]&&(this._extendedAttrs[i]=e._extendedAttrs[t])}_copySparseMapsFrom(e){this._combined={},this._extendedAttrs={};for(let t=0;tthis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){const e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(const e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),this._clearTimeout.value||this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=(0,s.disposableTimeout)(()=>{const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this.clear():this._scheduleClearTimeout(15e3-e)},e)}}t.BufferLineStringCache=o},9384(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732(e,t){function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,o,n){const a=[];for(let h=0;h=h&&r0&&(e>_||0===d[e].getTrimmedLength());e--)v++;v>0&&(a.push(h+d.length-v),a.push(v)),h+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],o=0;for(let n=0;nl&&(n-=l,a++);const c=2===e[a].getWidth(n-1);c&&n--;const d=c?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},4097(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(4812),r=i(1073),o=i(8636);class n extends s.Disposable{constructor(e,t,i){super(),this._optionsService=e,this._bufferService=t,this._logService=i,this._normalBuffer=this._register(new s.MutableDisposable),this._altBuffer=this._register(new s.MutableDisposable),this._onBufferActivate=this._register(new o.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=n},3055(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),o=i(5451);class n extends o.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new o.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new n;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor())return!1;if(this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor())return!1;if(this.isInverse()!==e.isInverse())return!1;if(this.isBold()!==e.isBold())return!1;if(this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;const t=this.isUnderlineColorDefault(),i=e.isUnderlineColorDefault();if(!t||!i){if(t!==i)return!1;if(this.getUnderlineColor()!==e.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==e.getUnderlineColorMode())return!1}}return this.isOverline()===e.isOverline()&&this.isBlink()===e.isBlink()&&this.isInvisible()===e.isInvisible()&&this.isItalic()===e.isItalic()&&this.isDim()===e.isDim()&&this.isStrikethrough()===e.isStrikethrough()}}t.CellData=n},8938(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(4812),r=i(8636);class o{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=o._nextId++,this._onDispose=this.register(new r.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,s.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=o,o._nextId=1},6760(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},706(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,s,r){const o={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?"OA":"":"UIKeyInputLeftArrow"===e.key?o.key=t?"OD":"":"UIKeyInputRightArrow"===e.key?o.key=t?"OC":"":"UIKeyInputDownArrow"===e.key&&(o.key=t?"OB":"");break;case 8:o.key=e.ctrlKey?"\b":"",e.altKey&&(o.key=""+o.key);break;case 9:if(e.shiftKey){o.key="";break}o.key="\t",o.cancel=!0;break;case 13:"c"===e.key&&e.ctrlKey?o.key="":o.key=e.altKey?"\r":"\r",o.cancel=!0;break;case 27:o.key="",e.altKey&&(o.key=""),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"D":t?"OD":"";break;case 39:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"C":t?"OC":"";break;case 38:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"A":t?"OA":"";break;case 40:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"B":t?"OB":"";break;case 45:e.shiftKey||e.ctrlKey||(o.key="[2~");break;case 46:o.key=n?"[3;"+(n+1)+"~":"[3~";break;case 36:o.key=n?"[1;"+(n+1)+"H":t?"OH":"";break;case 35:o.key=n?"[1;"+(n+1)+"F":t?"OF":"";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key="[5;"+(n+1)+"~":o.key="[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key="[6;"+(n+1)+"~":o.key="[6~";break;case 112:o.key=n?"[1;"+(n+1)+"P":"OP";break;case 113:o.key=n?"[1;"+(n+1)+"Q":"OQ";break;case 114:o.key=n?"[1;"+(n+1)+"R":"OR";break;case 115:o.key=n?"[1;"+(n+1)+"S":"OS";break;case 116:o.key=n?"[15;"+(n+1)+"~":"[15~";break;case 117:o.key=n?"[17;"+(n+1)+"~":"[17~";break;case 118:o.key=n?"[18;"+(n+1)+"~":"[18~";break;case 119:o.key=n?"[19;"+(n+1)+"~":"[19~";break;case 120:o.key=n?"[20;"+(n+1)+"~":"[20~";break;case 121:o.key=n?"[21;"+(n+1)+"~":"[21~";break;case 122:o.key=n?"[23;"+(n+1)+"~":"[23~";break;case 123:o.key=n?"[24;"+(n+1)+"~":"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(s&&!r||!e.altKey||e.metaKey)if(!s||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey){if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length)o.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":o.key="";break;case"Digit2":o.key="\0";break;case"Digit6":o.key=""}}else 65===e.keyCode&&(o.type=1);else{const t=i[e.keyCode],s=t?.[e.shiftKey?1:0];if(s)o.key=""+s;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=""+i}else if(32===e.keyCode)o.key=""+(e.ctrlKey?"\0":" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=""+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key="\0":e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key="":"/"===e.key?o.key="":219===e.keyCode?o.key="":220===e.keyCode?o.key="":221===e.keyCode&&(o.key="")}return o};const i={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},7241(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyKeyboard=void 0,t.KittyKeyboard=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){const t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e,t){const i=this._getNumpadKeyCode(e);if(void 0!==i)return i;const s=this._getModifierKeyCode(e);if(void 0!==s)return s;const r=this._functionalKeyCodes[e.key];if(void 0!==r)return r;if((e.shiftKey||t&&e.altKey)&&e.code){if(e.code.startsWith("Digit")&&6===e.code.length){const t=e.code.charAt(5);if(t>="0"&&t<="9")return t.charCodeAt(0)}if(e.code.startsWith("Key")&&4===e.code.length)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(1===e.key.length){const t=e.key.codePointAt(0);return t>=65&&t<=90?t+32:t}}_isModifierKey(e){return"Shift"===e.key||"Control"===e.key||"Alt"===e.key||"Meta"===e.key}_isLockKey(e){return"CapsLock"===e.key||"NumLock"===e.key||"ScrollLock"===e.key}_buildCsiLetterSequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"["+e}_buildSs3Sequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"O"+e}_buildCsiTildeSequence(e,t,i,s){const r=s&&1!==i;let o="["+e;return(t>0||r)&&(o+=";"+(t>0?t:"1"),r&&(o+=":"+i)),o+="~",o}_buildCsiUSequence(e,t,i,s,r,o,n){const a=!!(2&r);let h,l="["+t;4&r&&e.shiftKey&&1===e.key.length&&!o&&!n&&(h=e.key.codePointAt(0),l+=":"+h);const c=16&r&&3!==s&&1===e.key.length&&!o&&!n&&!e.ctrlKey?e.key.codePointAt(0):void 0,d=a&&1!==s&&(3===s||void 0===c);return(i>0||d||void 0!==c)&&(l+=";",i>0?l+=i:d&&(l+="1"),d&&(l+=":"+s)),void 0!==c&&(l+=";"+c),l+="u",l}evaluate(e,t,i=1,s=!1){const r={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(e),n=this._isModifierKey(e),a=!!(2&t);if(!a&&3===i)return r;if(n&&!(8&t))return r;if(this._isLockKey(e)&&!(8&t))return r;const h=this._csiLetterKeys[e.key];if(h)return r.key=this._buildCsiLetterSequence(h,o,i,a),r.cancel=!0,r;const l=this._ss3FunctionKeys[e.key];if(l)return r.key=this._buildSs3Sequence(l,o,i,a),r.cancel=!0,r;const c=this._csiTildeKeys[e.key];if(void 0!==c)return r.key=this._buildCsiTildeSequence(c,o,i,a),r.cancel=!0,r;const d=this._getKeyCode(e,s);if(void 0===d)return r;const _=13===d||9===d||127===d;if(_&&3===i&&!(8&t))return r;const u=void 0!==this._functionalKeyCodes[e.key]||void 0!==this._getNumpadKeyCode(e);if(8&t||a&&3===i||(1&t||a)&&(u&&!_||o>0&&1!==e.key.length||o-1>1))r.key=this._buildCsiUSequence(e,d,o,i,t,u,n),r.cancel=!0;else{const t=13===d?"\r":9===d?"\t":127===d?"":void 0;t?r.key=t:1!==e.key.length||e.ctrlKey||e.altKey||e.metaKey||(r.key=e.key)}return r}static shouldUseProtocol(e){return e>0}}},726(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let o=r;o=i)return this._interim=r,s;const n=e.charCodeAt(o);56320<=n&&n<=57343?t[s++]=1024*(r-55296)+n-56320+65536:(t[s++]=r,t[s++]=n);continue}65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,o,n,a,h=0,l=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let o,n=0;for(;(o=this.interim[++n])&&n<4;)r<<=6,r|=63&o;const a=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=a-n;for(;l=i)return 0;if(o=e[l++],128!=(192&o)){l--,s=!0;break}this.interim[n++]=o,r<<=6,r|=63&o}s||(2===a?r<128?l--:t[h++]=r:3===a?r<2048||r>=55296&&r<=57343||65279===r||(t[h++]=r):r<65536||r>1114111||(t[h++]=r)),this.interim.fill(0)}const c=i-4;let d=l;for(;d=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(a=(31&s)<<6|63&r,a<128){d--;continue}t[h++]=a}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(a=(15&s)<<12|(63&r)<<6|63&o,a<2048||a>=55296&&a<=57343||65279===a)continue;t[h++]=a}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=o,h;if(n=e[d++],128!=(192&n)){d--;continue}if(a=(7&s)<<18|(63&r)<<12|(63&o)<<6|63&n,a<65536||a>1114111)continue;t[h++]=a}}return h}}},7428(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],o=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let n;t.UnicodeV6=class{constructor(){if(this.version="6",!n){n=new Uint8Array(65536),n.fill(1),n[0]=0,n.fill(0,1,32),n.fill(0,127,160),n.fill(2,4352,4448),n[9001]=2,n[9002]=2,n.fill(2,11904,42192),n[12351]=1,n.fill(2,44032,55204),n.fill(2,63744,64256),n.fill(2,65040,65050),n.fill(2,65072,65136),n.fill(2,65280,65377),n.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},9249(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Win32InputMode=void 0,t.Win32InputMode=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){const t=this._codeToVk[e.code];return void 0!==t?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if("Enter"===e.key)return 10;if("Backspace"===e.key)return 127}const t=this._keyToControlChar[e.key];if(void 0!==t)return t;if(1===e.key.length){const t=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&("ControlRight"===e.code?t|=4:t|=8),e.altKey&&("AltRight"===e.code?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){return{type:0,cancel:!0,key:`[${this._getVirtualKeyCode(e)};${this._getScanCode(e)};${this._getUnicodeChar(e)};${t?1:0};${this._getControlKeyState(e)};1_`}}}},3562(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(3132),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._innerWriteTimer=this._register(new s.TimeoutTimer),this._onWriteParsed=this._register(new o.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._register((0,r.toDisposable)(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed)return;if(this._isSyncWriting)return;let e;this._isSyncWriting=!0;let t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);const i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>{this._store.isDisposed||(performance.now()-i>=12?this._scheduleInnerWrite(0,e):this._innerWrite(i,e))};return void s.catch(e=>(queueMicrotask(()=>{throw e}),Promise.resolve(!1))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(t.startsWith("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(t.startsWith("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,o]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(o,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},2607(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ApcHandler=t.ApcParser=void 0;const s=i(726),r=i(4220),o=[];t.ApcParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._ident=0}start(e){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._ident,"START")}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}end(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"END",e);this._active=o,this._ident=0}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.ApcHandler=n,n._payloadLimit=1e7},9823(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),o=i(4220),n=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=n,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=n,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=n,this._ident=0}};const a=new r.Params;a.addParam(0);class h{constructor(e){this._handler=e,this._data=new o.LimitedStringBuilder(h._payloadLimit),this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString(),this._params),t instanceof Promise))return t.then(e=>(this._params=a,this._data.reset(),this._hitLimit=!1,e));return this._params=a,this._data.reset(),this._hitLimit=!1,t}}t.DcsHandler=h,h._payloadLimit=1e7},6717(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(4812),r=i(7262),o=i(1346),n=i(9823),a=i(2607);class h{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;rt),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const o=i(0,17);e.setDefault(1,0),e.addMany(s,0,2,0);for(const t of o)e.addMany([24,26,153,154],t,3,0),e.addMany(i(128,144),t,3,0),e.addMany(i(144,152),t,3,0),e.add(156,t,0,0),e.add(27,t,11,1),e.add(157,t,4,8),e.addMany([152,158],t,0,7),e.add(159,t,11,14),e.add(155,t,11,3),e.add(144,t,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,11,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany(i(32,48),14,9,15),e.addMany(i(48,127),14,15,16),e.addMany(i(48,127),15,15,16),e.addMany(r,15,0,15),e.addMany(i(32,48),15,9,15),e.add(127,15,0,15),e.addMany(s,16,16,16),e.addMany(r,16,0,16),e.addMany(i(8,14),16,16,16),e.add(127,16,0,16),e.addMany([27,156,24,26],16,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e.add(l,16,16,16),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new o.OscParser),this._dcsParser=this._register(new n.DcsParser),this._apcParser=this._register(new a.ApcParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i<60||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);this._escHandlers[i]??=[];const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){const i=e.charCodeAt(0);this._executeHandlers[i]=t,i<24&&(this._executeHandlersArr[i]=t)}clearExecuteHandler(e){const t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);this._csiHandlers[i]??=[];const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r,o,n=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let r=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](this._params),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 4:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=255&this._parseStack.transition}for(let i=n;i=60&&n<=63&&(this._collect=n,s++);let a=!1;for(;s=48&&n<=57)this._params.addDigit(n-48);else if(59===n)this._params.addParam(0);else{if(58!==n){if(n>=64&&n<=126){const e=this._csiHandlers[this._collect<<8|n];let t=e?e.length-1:-1;for(;t>=0&&(o=e[t](this._params),!0!==o);t--)if(o instanceof Promise)return r=1792,this._preserveStack(3,e,t,r,s),o;t<0&&this._csiHandlerFb(this._collect<<8|n,this._params),this.precedingJoinState=0,i=s,this.currentState=0,a=!0;break}break}this._params.addSubParam(-1)}a||(i=s-1,this.currentState=4);continue}switch(r=this._transitions.table[this.currentState<<8|(s>8){case 2:let n=i;const a=t-4;for(;n=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l););if(n>=a)for(;n=32&&(e[n]<=126||e[n]>=l);)n++;this._printHandler(e,i,n),i=n-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:i,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:const h=this._csiHandlers[this._collect<<8|s];let c=h?h.length-1:-1;for(;c>=0&&(o=h[c](this._params),!0!==o);c--)if(o instanceof Promise)return this._preserveStack(3,h,c,r,i),o;c<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do{switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}}while(++i47&&s<60);i--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:const d=this._escHandlers[this._collect<<8|s];let _=d?d.length-1:-1;for(;_>=0&&(o=d[_](),!0!==o);_--)if(o instanceof Promise)return this._preserveStack(4,d,_,r,i),o;_<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let r=i+1;;++r)if(r>=t||24===(s=e[r])||26===s||27===s||s>127&&s=t||(s=e[r])<32||s>127&&s=32&&e[s]<127||e[s]>=8&&e[s]<14||e[s]>=l))){this._apcParser.put(e,i,s),i=s-1;break}break;case 17:if(o=this._apcParser.end(24!==s&&26!==s),o)return this._preserveStack(7,[],0,r,i),o;27===s&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0}this.currentState=255&r}}}t.EscapeSequenceParser=c},1346(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(726),r=i(4220),o=[];t.OscParser=class{constructor(){this._state=0,this._active=o,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,s.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=o,this._id=-1,this._state=0}}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.OscHandler=n,n._payloadLimit=1e7},7262(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;class i{static fromArray(e){const t=new i;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new i(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>2147483647?2147483647:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=e>2147483647?2147483647:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const i=this._digitIsSub?this._subParams:this.params,s=i[t-1];i[t-1]=~s?Math.min(10*s+e,2147483647):e}}t.Params=i},3027(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new o.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=n},6097(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,e=>t(e.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(e,i)=>t(e,i.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}}},4335(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=void 0;const o=i(4812),n=i(4097),a=i(6501),h=i(8636);let l=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e,t){super(),this.isUserScrolling=!1,this._onResize=this._register(new h.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new h.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new n.BufferSet(e,this,t)),this._register(this.buffers.onBufferActivate(e=>{this._onScroll.fire(e.activeBuffer.ydisp)}))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,o=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;o===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(o+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=o-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(o,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=l,t.BufferService=l=s([r(0,a.IOptionsService),r(1,a.ILogService)],l)},5746(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},4071(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const o=i(4812),n=i(6501),a=i(8636),h=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0});let c=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new a.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new a.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new a.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new a.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}reset(){this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onBinary.fire(e))}};t.CoreService=c,t.CoreService=c=s([r(0,n.IBufferService),r(1,n.ILogService),r(2,n.IOptionsService)],c)},4720(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationLineCache=t.DecorationService=void 0;const o=i(3132),n=i(4103),a=i(4812),h=i(6501),l=i(3087),c=i(8636);let d=0,_=0,u=class extends a.Disposable{get decorations(){return this._decorations.values()}constructor(e,t){super(),this._logService=e,this._bufferService=t,this._lineCache=this._register(new f),this._onDecorationRegistered=this._register(new c.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new c.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._decorations=new l.SortedList(e=>e?.marker.line,this._logService),this._register((0,a.toDisposable)(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}registerDecoration(e){if(e.marker.isDisposed)return;const t=new p(e);if(t){const e=t.marker.onDispose(()=>t.dispose()),i=t.onDispose(()=>{i.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),e.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,i){const s=this._lineCache.getDecorationsOnLine(t);if(s)for(const t of s)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&(yield t)}forEachDecorationAtCell(e,t,i,s){const r=this._lineCache.getDecorationsOnLine(t);if(r)for(const t of r)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&s(t)}};t.DecorationService=u,t.DecorationService=u=s([r(0,h.ILogService),r(1,h.IBufferService)],u);class f extends a.Disposable{constructor(){super(...arguments),this._decorationsByLine=new Map,this._decorations=new Set,this._bufferLineListeners=this._register(new a.MutableDisposable),this._lineIndexSyncTimer=this._register(new o.MicrotaskTimer),this._lineIndexSyncCallbacks=[]}clear(){this._lineIndexSyncCallbacks.length=0,this._lineIndexSyncTimer.cancel(),this._decorationsByLine.clear(),this._decorations.clear()}add(e){this._decorations.add(e),this._addToLineBuckets(e)}remove(e){this._decorations.delete(e),this._removeFromLineBuckets(e)}getDecorationsOnLine(e){return this._decorationsByLine.get(e)}attachToBufferLines(e){const t=new a.DisposableStore;this._bufferLineListeners.value=t,t.add(e.onTrim(e=>this._handleBufferLinesTrim(e))),t.add(e.onInsert(e=>this._handleBufferLinesInsert(e))),t.add(e.onDelete(e=>this._handleBufferLinesDelete(e)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){const t=e.marker.line;if(t<0)return;e._indexedStartLine=t;const i=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{const e=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(const t of e)t()})}_handleBufferLinesTrim(e){if(e<=0)return;const t=new Map;for(const[i,s]of this._decorationsByLine){const r=i-e;r<0||this._mergeLineBucket(t,r,s)}this._decorationsByLine.clear();for(const[e,i]of t)this._decorationsByLine.set(e,i);for(const t of this._decorations)t.marker.isDisposed||(t._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,i){const s=e.get(t);if(s)for(let e=0,t=i.length;et&&(s.push(e),this._removeFromLineBuckets(e))}const r=new Map;for(const[e,s]of this._decorationsByLine){const o=e>=t?e+i:e;this._mergeLineBucket(r,o,s)}this._decorationsByLine.clear();for(const[e,t]of r)this._decorationsByLine.set(e,t);for(const e of this._decorations)e.marker.isDisposed||e._indexedStartLine>=t&&(e._indexedStartLine=e.marker.line);for(const e of s)this._addToLineBuckets(e)}_applyBufferLinesDelete(e){const t=e.index+e.amount,i=new Map;for(const[s,r]of this._decorationsByLine){if(s>=e.index&&s=t?s-e.amount:s;this._mergeLineBucket(i,o,r)}this._decorationsByLine.clear();for(const[e,t]of i)this._decorationsByLine.set(e,t);const s=[];for(const i of this._decorations){if(i.marker.isDisposed)continue;const r=i._indexedStartLine,o=this._getDecorationHeight(i);r>=t?i._indexedStartLine=i.marker.line:rt&&s.push(i)}for(const e of s)this._reindexDecoration(e)}}t.DecorationLineCache=f;class p extends a.DisposableStore{get backgroundColorRGB(){return null===this._cachedBg&&(this.options.backgroundColor?this._cachedBg=n.css.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return null===this._cachedFg&&(this.options.foregroundColor?this._cachedFg=n.css.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}constructor(e){super(),this.options=e,this.onRenderEmitter=this.add(new c.Emitter),this.onRender=this.onRenderEmitter.event,this._onDispose=this.add(new c.Emitter),this.onDispose=this._onDispose.event,this._cachedBg=null,this._cachedFg=null,this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}dispose(){this._onDispose.fire(),super.dispose()}}},6025(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class o{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=o,t.InstantiationService=class{constructor(){this._services=new o,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort((e,t)=>e.index-t.index),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const o=i.length>0?i[0].index:t.length;if(t.length!==o)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${o+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0;const o=i(4812),n=i(6501),a={trace:n.LogLevelEnum.TRACE,debug:n.LogLevelEnum.DEBUG,info:n.LogLevelEnum.INFO,warn:n.LogLevelEnum.WARN,error:n.LogLevelEnum.ERROR,off:n.LogLevelEnum.OFF};let h=class extends o.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=n.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function n(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const a=String.fromCharCode,h={DEFAULT:e=>{const t=[n(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${a(t[0])}${a(t[1])}${a(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.x};${e.y}${t}`}};class l extends s.Disposable{constructor(){super(),this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=this._register(new r.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(o))this.addProtocol(e,o[e]);for(const e of Object.keys(h))this.addEncoding(e,h[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return!this._customWheelEventHandler||!1!==this._customWheelEventHandler(e)}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return"DEFAULT"===this._activeEncoding}get isPixelEncoding(){return"SGR_PIXELS"===this._activeEncoding}}t.MouseStateService=l},56(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(4812),r=i(701),o=i(8636);t.DEFAULT_OPTIONS={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}};const n=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new o.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{-1!==e.indexOf(i)&&t()})}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=n.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"blinkIntervalDuration":if((i=Math.floor(i))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const o=i(6501);let n=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose(()=>this._removeMarkerFromLink(s,i)),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const o=t.addMarker(t.ybase+t.y),n={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(n,o)),this._entriesWithId.set(n.key,n),this._dataByLinkId.set(n.id,n),n.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every(e=>e.line!==t)){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose(()=>this._removeMarkerFromLink(i,e))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=n,t.OscLinkService=n=s([r(0,o.IBufferService)],n)},6201(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e.di$dependencies||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const i=function(e,t,s){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,i){t.di$target===t?t.di$dependencies.push({id:e,index:i}):(t.di$dependencies=[{id:e,index:i}],t.di$target=t)}(i,e,s)};return i._id=e,t.serviceRegistry.set(e,i),i},t.serviceRegistry=new Map},6501(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.IMouseStateService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.IMouseStateService=(0,s.createDecorator)("MouseStateService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8636);class r{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.Emitter,this.onChange=this._onChange.event}static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e,this._active||(this.activeVersion=e.version)}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let o=0;o=s)return t+this.wcwidth(n);const i=e.charCodeAt(o);56320<=i&&i<=57343?n=1024*(n-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(n,i);let h=r.extractWidth(a);r.extractShouldJoin(a)&&(h-=r.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=r}},t={};return function i(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s].call(o.exports,o,o.exports,i),o.exports}(6081)})()); -+!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,()=>(()=>{"use strict";var e={2840(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new c.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(e=>this._handleResize(e.rows))),this._register(this._terminal.onRender(e=>this._refreshRows(e.start,e.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(e=>this._handleChar(e))),this._register(this._terminal.onLineFeed(()=>this._handleChar("\n"))),this._register(this._terminal.onA11yTab(e=>this._handleTab(e))),this._register(this._terminal.onKey(e=>this._handleKey(e.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register((0,f.addDisposableListener)(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,d.toDisposable)(()=>{this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent=l.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],o=e?.translateToString(!0,void 0,void 0,t)||"",n=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===o.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=o,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",n),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent===l.tooMuchOutput.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,o;if(0===t?(r=i,o=this._rowElements.pop(),this._rowContainer.removeChild(o)):(r=this._rowElements.shift(),o=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),o.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let o=t=this._terminal.cols&&(++s,o=0),{row:s,column:o}},o=r(t),n=r(i);if(o&&n){if(o.row>n.row||o.row===n.row&&o.column>=n.column)throw new Error("invalid range");this._terminal.select(o.column,o.row,(n.row-o.row)*this._terminal.cols-o.column+n.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;ethis._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(e=>this._reportWindowsOptions(e))),this._register(this._inputHandler.onColor(e=>this._handleColorEvent(e))),this._register(I.EventUtils.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(I.EventUtils.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(I.EventUtils.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(I.EventUtils.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(e=>this._afterResize(e.cols,e.rows))),this._register((0,N.toDisposable)(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i;switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=E.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`]${i};${(0,M.toRgbString)(s)}\\`);break;case 1:if("ansi"===e)this._themeService.modifyColors(e=>e.ansi[t.index]=E.channels.toColor(...t.color));else{const i=e;this._themeService.modifyColors(e=>e[i]=E.channels.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index)}}}_reportColorScheme(){if(!this._themeService)return;const e=E.rgb.relativeLuminance(this._themeService.colors.background.rgba>>8)>8)?1:2;this.coreService.triggerDataEvent(`[?997;${e}n`)}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),o=this._renderService.dimensions.css.cell.width*r,n=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=n+"px",this.textarea.style.width=o+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,H.addDisposableListener)(this.element,"copy",e=>{this.hasSelection()&&(0,a.copyHandler)(e,this._selectionService)}));const e=e=>(0,a.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,H.addDisposableListener)(this.textarea,"paste",e)),this._register((0,H.addDisposableListener)(this.element,"paste",e)),x.isFirefox?this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>{2===e.button&&(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register((0,H.addDisposableListener)(this.element,"contextmenu",e=>{(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),x.isLinux&&this._register((0,H.addDisposableListener)(this.element,"auxclick",e=>{1===e.button&&(0,a.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)}))}_bindKeys(){this._register((0,H.addDisposableListener)(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register((0,H.addDisposableListener)(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register((0,H.addDisposableListener)(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register((0,H.addDisposableListener)(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",e=>this.element.classList.toggle("allow-transparency",e))),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,H.addDisposableListener)(this.screenElement,"mousemove",e=>this.updateCursorStyle(e))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",h.promptLabel.get()),x.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(g.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??("undefined"!=typeof window?window.document:null))),this._instantiationService.setService(C.ICoreBrowserService,this._coreBrowserService),this._register((0,H.addDisposableListener)(this.textarea,"focus",e=>this._handleTextAreaFocus(e))),this._register((0,H.addDisposableListener)(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(p.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(C.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(k.ThemeService),this._instantiationService.setService(C.IThemeService,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(v.CharacterJoinerService),this._instantiationService.setService(C.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(w.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(C.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange(e=>this._onRender.fire(e))),this._register(this._renderService.onDimensionsChange(e=>this._onDimensionsChange.fire({css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}))),this.onResize(e=>this._renderService.resize(e.cols,e.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(u.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(S.MouseCoordsService),this._instantiationService.setService(C.IMouseCoordsService,this._mouseCoordsService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(O.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(e){this._logService.error("onWillOpen handler threw an exception",e)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(c.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(y.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(C.ISelectionService,this._selectionService),this._mouseService=this._instantiationService.createInstance(b.MouseService),this._instantiationService.setService(C.IMouseService,this._mouseService),this._register(this._selectionService.onRequestScrollLines(e=>this.scrollLines(e.amount,e.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()})),this._register(I.EventUtils.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(d.BufferDecorationRenderer,this.screenElement)),this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>this._selectionService.handleMouseDown(e))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",e=>this._handleScreenReaderModeOptionChange(e)));const r=this.options.scrollbar?.showScrollbar??!0,o=this.options.scrollbar?.width;r&&o&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",e=>{const t=(e?.showScrollbar??!0)&&!!e?.width;!this._overviewRulerRenderer&&t&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:e=>this._viewport?.handleTouchScroll(e)},e=>this._register(e),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(f.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,a.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),e.preventDefault(),e.stopPropagation(),!1}if(1===i.type&&this.selectAll(),this._isThirdLevelShift(this.browser,e))return!0;if(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)return!0;if(!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;""!==i.key&&"\r"!==i.key||(this.textarea.value="");const s=this._keyboardService.useWin32InputMode&&W(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return;W(e)||this.focus();const t=this._keyboardService.evaluateKeyUp(e);if(t?.key){const i=this._keyboardService.useWin32InputMode&&W(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this._compositionHelper.keypress(t)||this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;efunction(e){const t=l(e);for(t.animFrameRequested=!1,t.current=t.next,t.next=[],t.inAnimationFrameRunner=!0;t.current.length>0;)t.current.sort(a.sort),t.current.shift().execute();t.inAnimationFrameRunner=!1}(e))),r};const s=i(3132);function r(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView;const i=e;return i?.view?i.view:window}class o{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s,e.addEventListener(t,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function n(e,t,i,s){return new o(e,t,i,s)}t.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class a{constructor(e,t){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){console.error(e)}}static sort(e,t){return t.priority-e.priority}}const h=new Map;function l(e){let t=h.get(e);return t||(t={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},h.set(e,t)),t}class c extends s.IntervalTimer{constructor(e){super(),this._defaultTarget=e?r(e):void 0}cancelAndSet(e,t,i){super.cancelAndSet(e,t,i??this._defaultTarget??window)}}t.WindowIntervalTimer=c},8906(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const o=i(4812),n=i(6501),a=i(7098),h=i(8636),l=i(4159);let c=class extends o.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseCoordsService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new h.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new h.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,o.toDisposable)(()=>{(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register((0,l.addDisposableListener)(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register((0,l.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach(e=>{e.link.dispose&&e.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,t=>{if(this._isMouseOut)return;const r=t?.map(e=>({link:e}));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=o;e<=n;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element);e&&this._askForLink(e,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t){const i=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseCoordsService),r(2,a.IRenderService),r(3,n.IBufferService),r(4,a.ILinkProviderService)],c)},7721(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const o={get:()=>r,set:e=>r=e};t.tooMuchOutput=o},3285(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const o=i(3055),n=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new o.CellData}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=this._workCell,n=i.getTrimmedLength();let a=-1,l=-1,c=!1;for(let t=0;tr?r.activate(e,t,d):h(0,t),hover:(e,t)=>r?.hover?.(e,t,d),leave:(e,t)=>r?.leave?.(e,t,d)})}c=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,a=o.extended.urlId):(l=-1,a=-1)}}t(s)}_getRangeWithLineWrap(e,t,i,s){let r=e,o=t,n=e,a=i;for(;0===o;){const e=this._bufferService.buffer.lines.get(r-1);if(!e?.isWrapped)break;const t=this._bufferService.buffer.lines.get(r-2);if(!t)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,i-1,s))break;let n=i-1;for(;n>0&&this._hasUrlId(t,n-1,s);)n--;r--,o=n}for(;;){const e=this._bufferService.buffer.lines.get(n-1);if(!e)break;if(a!==e.getTrimmedLength())break;const t=this._bufferService.buffer.lines.get(n);if(!t?.isWrapped)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,0,s))break;let r=1;for(;rthis._innerRefresh()),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)void 0!==this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],o=t[i/6%6|0],n=t[i%6];e.push({css:s.channels.toCss(r,o,n),rgba:s.channels.toRgba(r,o,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const o=i(7098),n=i(4812),a=i(6501),h=i(4159),l=i(8566),c=i(8636),d=i(7880);let _=class extends n.Disposable{constructor(e,t,i,s,r,o,a,_,u){super(),this._bufferService=i,this._coreService=r,this._optionsService=_,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1,this._needsSyncOnRender=!1;const f=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,h.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{f.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new l.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},f)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(o.onProtocolChange(e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{e.style.backgroundColor=a.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=a.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,n.toDisposable)(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,n.toDisposable)(()=>this._styleElement.remove())),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${a.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${a.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${a.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(e=>this._handleScroll(e)))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){const e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,i=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:i,verticalHasArrows:t}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._coreService.decPrivateModes.synchronizedOutput?this._needsSyncOnRender=!0:(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1))}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}handleTouchScroll(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};t.Viewport=_,t.Viewport=_=s([r(2,a.IBufferService),r(3,o.ICoreBrowserService),r(4,a.ICoreService),r(5,a.IMouseStateService),r(6,o.IThemeService),r(7,a.IOptionsService),r(8,o.IRenderService)],_)},4196(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const o=i(7098),n=i(4812),a=i(6501);let h=class extends n.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(e=>this._removeDecoration(e))),this._register((0,n.toDisposable)(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h,t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,o.ICoreBrowserService),r(3,a.IDecorationService),r(4,o.IRenderService)],h)},957(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const o=i(957),n=i(7098),a=i(4812),h=i(6501),l={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=class extends a.Disposable{get _width(){const e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??1?e?.width??0:0}constructor(e,t,i,s,r,n,h,l){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=h,this._coreBrowserService=l,this._colorZoneStore=new o.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)(()=>this._canvas?.remove()));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register((0,a.toDisposable)(()=>{void 0!==this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;const e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_,t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,n.IRenderService),r(5,h.IOptionsService),r(6,n.IThemeService),r(7,n.ICoreBrowserService)],_)},3618(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const o=i(7098),n=i(6501);let a=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,o){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=o,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent="",this._pendingKeypressData=""}compositionstart(){this._isComposing=!0;const e=this._textarea.selectionStart??this._textarea.value.length,t=this._textarea.selectionEnd??e;this._compositionPosition.start=Math.min(e,t),this._compositionPosition.end=Math.max(e,t),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=`‎${e.data}‎`,this.updateCompositionElements(),setTimeout(()=>{const e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}keypress(e){return!!this._isSendingComposition&&(this._pendingKeypressData+=e,!0)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._pendingKeypressData="",this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){let i;if(this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const s=this._textarea.value,r=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;i=s.substring(e.start,Math.max(e.start,r))}this._sendCompositionInput(i)}},0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._sendCompositionInput(e)}}_sendCompositionInput(e){const t=this._pendingKeypressData;if(!e.includes(t))if(t.includes(e))e=t;else{let i=Math.min(e.length,t.length);for(;i>0&&!e.endsWith(t.substring(0,i));)i--;let s=Math.min(e.length,t.length);for(;s>0&&!t.endsWith(e.substring(0,s));)s--;e=i>s?e+t.substring(i):t+e.substring(s)}this._pendingKeypressData="",e.length>0&&this._coreService.triggerDataEvent(e,!0)}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;const e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0),0)}}};t.CompositionHelper=a,t.CompositionHelper=a=s([r(2,n.IBufferService),r(3,n.IOptionsService),r(4,n.ICoreService),r(5,o.IRenderService)],a)},5251(e,t){function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),o=parseInt(r.getPropertyValue("padding-left"),10),n=parseInt(r.getPropertyValue("padding-top"),10);return[t.clientX-s.left-o,t.clientY-s.top-n]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,o,n,a,h,l){if(!n)return;const c=i(e,t,s);return c[0]=Math.ceil((c[0]+(l?a/2:0))/a),c[1]=Math.ceil(c[1]/h),c[0]=Math.min(Math.max(c[0],1),r+(l?1:0)),c[1]=Math.min(Math.max(c[1],1),o),c}},9686(e,t){function i(e,t,i,o){const h=e-s(e,i),l=t-s(t,i),c=Math.abs(h-l)-function(e,t,i){let o=0;const n=e-s(e,i),a=t-s(t,i);for(let s=0;s=0&&et?"A":"B"}function o(e,t,i,s,r,o){let n=e,a=t,h="";for(;(n!==i||a!==s)&&a>=0&&ao.cols-1?(h+=o.buffer.translateBufferLineToString(a,!1,e,n),n=0,e=0,a++):!r&&n<0&&(h+=o.buffer.translateBufferLineToString(a,!1,0,e+1),n=o.cols-1,e=n,a--);return h+o.buffer.translateBufferLineToString(a,!1,e,n)}function n(e,t){return""+(t?"O":"[")+e}function a(e,t){e=Math.floor(e);let i="";for(let s=0;s0?h-s(h,l):t;const _=h,u=function(e,t,r,o,n,a){let h;return h=i(t,o,n,a).length>0?o-s(o,n):t,e=r&&he?"D":"C",a(Math.abs(l-e),n(d,h));d=c>t?"D":"C";const _=Math.abs(c-t);return a(function(e,t){return t.cols-e}(c>t?e:l,r)+(_-1)*r.cols+1+((c>t?l:e)-1),n(d,h))}},6081(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(f.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new _.ParserApi(this._core)}get unicode(){return this._checkProposedApi(),new u.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new d.BufferNamespaceApi(this._core))}get markers(){return this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.mouseStateService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return a.promptLabel.get()},set promptLabel(e){a.promptLabel.set(e)},get tooMuchOutput(){return a.tooMuchOutput.get()},set tooMuchOutput(e){a.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(p of e)if(p===1/0||isNaN(p)||p%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(p of e)if(p&&(p===1/0||isNaN(p)||p%1!=0||p<0))throw new Error("This API only accepts positive integers")}}t.Terminal=v},3955(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const o=i(1433),n=i(2744),a=i(9176),h=i(6181),l=i(2274),c=i(654),d=i(7098),_=i(4103),u=i(4812),f=i(6501),p=i(8636),v=i(4159);let g=1,m=class extends u.Disposable{constructor(e,t,i,s,r,a,d,_,f,m,b,w,y,C){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=d,this._charSizeService=f,this._optionsService=m,this._bufferService=b,this._coreService=w,this._coreBrowserService=y,this._themeService=C,this._terminalClass=g++,this._rowElements=[],this._selectionRenderModel=(0,l.createSelectionRenderModel)(),this._lastSelectionColumnMode=!1,this._rowHasBlinkingCells=[],this._rowHasBlinkingCellsCount=0,this._onRequestRedraw=this._register(new p.Emitter),this.onRequestRedraw=this._onRequestRedraw.event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(e=>this._injectCss(e))),this._injectCss(this._themeService.colors),this._rowFactory=_.createInstance(o.DomRendererRowFactory,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(e=>this._handleLinkHover(e))),this._register(this._linkifier2.onHideLinkUnderline(e=>this._handleLinkLeave(e))),this._cursorBlinkStateManager=new S(this._rowContainer,this._coreBrowserService),this._register((0,v.addDisposableListener)(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register((0,u.toDisposable)(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new c.TextBlinkStateManager(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register((0,u.toDisposable)(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new n.WidthCache,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${_.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${i} { color: ${s.css}; }${this._terminalSelector} .xterm-fg-${i}.xterm-dim { color: ${_.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .xterm-bg-${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR} { color: ${_.color.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${_.color.multiplyOpacity(_.color.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,i){const s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i);let r=0,o=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(r=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow));let n=0,a=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),this._selectionRenderModel.hasSelection){const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,o=this._selectionRenderModel.viewportCappedStartRow,h=this._selectionRenderModel.viewportCappedEndRow;n=o,a=h;const l=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];l.appendChild(this._createSelectionElement(o,i?t[0]:e[0],i?e[0]:t[0],h-o+1))}else{const i=s===o?e[0]:0,n=o===r?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(o,i,n));const a=h-o-1;if(l.appendChild(this._createSelectionElement(o+1,0,this._bufferService.cols,a)),o!==h){const e=r===h?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(h,0,e))}}this._selectionContainer.appendChild(l)}let h=Math.min(r,n),l=Math.max(o,a);if(l>=0){h=Math.max(h,0),l=Math.min(l,s-1);const e=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&e>=0&&ethis.dimensions.css.canvas.width&&(n=this.dimensions.css.canvas.width-o),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${o}px`,r.style.width=`${n}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),o=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,n=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle,h={hasBlinkingCells:!1};for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l];if(!t)continue;const c=i.lines.get(e);c?(t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,n,a,r,o,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,h)),this._setRowBlinkState(l,h.hasBlinkingCells)):(t.replaceChildren(),this._setRowBlinkState(l,!1))}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,o){i<0&&(e=0),s<0&&(t=0);const n=this._bufferService.rows-1;i=Math.max(Math.min(i,n),0),s=Math.max(Math.min(s,n),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,l=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle,u={hasBlinkingCells:!1};for(let n=i;n<=s;++n){const f=n+a.ydisp,p=this._rowElements[n];if(!p)continue;const v=a.lines.get(f);v?(p.replaceChildren(...this._rowFactory.createRow(v,f,f===h,d,_,l,c,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,o?n===i?e:0:-1,o?(n===s?t:r)-1:-1,u)),this._setRowBlinkState(n,u.hasBlinkingCells)):(p.replaceChildren(),this._setRowBlinkState(n,!1))}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};t.DomRenderer=m,t.DomRenderer=m=s([r(7,f.IInstantiationService),r(8,d.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,f.ICoreService),r(12,d.ICoreBrowserService),r(13,d.IThemeService)],m);class S{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){void 0!==this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}}},1433(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const o=i(9176),n=i(8938),a=i(3055),h=i(6501),l=i(4103),c=i(7098),d=i(945),_=i(6181),u=i(5451);let f=class{constructor(e,t,i,s,r,o,n){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=o,this._themeService=n,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,c,_,f,p,v,g){const m=[];g&&(g.hasBlinkingCells=!1);const S=this._characterJoinerService.getJoinedCharacters(t),b=this._themeService.colors;let w,y=e.getNoBgTrimmedLength();i&&y=A,F=I,W=this._workCell;if(S.length>0&&I===S[0][0]&&N){const s=S.shift(),r=this._isCellInSelection(s[0],t);for(C=s[0]+1;C=s[1],N?(H=!0,W=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),F=s[1]-1,y=W.getWidth()):A=s[1]}const z=this._isCellInSelection(I,t),K=i&&I===a,U=O&&I>=p&&I<=v;g&&W.isBlink()&&(g.hasBlinkingCells=!0),!c&&W.isBlink()&&P.push("xterm-blink-hidden");let j=!1;this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{j=!0});let $=W.getChars()||n.WHITESPACE_CELL_CHAR;if(" "===$&&(W.isUnderline()||W.isOverline())&&($=" "),k=y*_-f.get($,W.isBold(),W.isItalic()),w){if(D&&(z&&T||!z&&!T&&W.bg===L)&&(z&&T&&b.selectionForeground||W.fg===x)&&W.extended.ext===R&&U===M&&k===B&&!K&&!H&&!j&&N){W.isInvisible()?E+=n.WHITESPACE_CELL_CHAR:E+=$,D++;continue}D&&(w.textContent=E),w=this._document.createElement("span"),D=0,E=""}else w=this._document.createElement("span");if(L=W.bg,x=W.fg,R=W.extended.ext,M=U,B=k,T=z,H&&a>=I&&a<=F&&(a=I),!this._coreService.isCursorHidden&&K&&this._coreService.isCursorInitialized)if(P.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&P.push("xterm-cursor-blink"),P.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":P.push("xterm-cursor-outline");break;case"block":P.push("xterm-cursor-block");break;case"bar":P.push("xterm-cursor-bar");break;case"underline":P.push("xterm-cursor-underline")}if(W.isBold()&&P.push("xterm-bold"),W.isItalic()&&P.push("xterm-italic"),W.isDim()&&P.push("xterm-dim"),E=W.isInvisible()?n.WHITESPACE_CELL_CHAR:W.getChars()||n.WHITESPACE_CELL_CHAR,W.isUnderline()&&(P.push(`xterm-underline-${W.extended.underlineStyle}`)," "===E&&(E=" "),!W.isUnderlineColorDefault()))if(W.isUnderlineColorRGB())w.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(W.getUnderlineColor()).join(",")})`;else{let e=W.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&W.isBold()&&e<8&&(e+=8),w.style.textDecorationColor=b.ansi[e].css}W.isOverline()&&(P.push("xterm-overline")," "===E&&(E=" ")),W.isStrikethrough()&&P.push("xterm-strikethrough"),U&&(w.style.textDecoration="underline");let q=W.getFgColor(),V=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();const G=!!W.isInverse();if(G){const e=q;q=X,X=e;const t=V;V=Y,Y=t}let J,Z,Q,ee=!1;switch(this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{"top"!==e.options.layer&&ee||(e.backgroundColorRGB&&(Y=50331648,X=e.backgroundColorRGB.rgba>>8&16777215,J=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,q=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),ee="top"===e.options.layer)}),!ee&&z&&(J=this._coreBrowserService.isFocused?b.selectionBackgroundOpaque:b.selectionInactiveBackgroundOpaque,X=J.rgba>>8&16777215,Y=50331648,ee=!0,b.selectionForeground&&(V=50331648,q=b.selectionForeground.rgba>>8&16777215,Z=b.selectionForeground)),ee&&P.push("xterm-decoration-top"),Y){case 16777216:case 33554432:Q=b.ansi[X],P.push(`xterm-bg-${X}`);break;case 50331648:Q=l.channels.toColor(X>>16,X>>8&255,255&X),this._addStyle(w,`background-color:#${(X>>>0).toString(16).padStart(6,"0")}`);break;default:G?(Q=b.foreground,P.push(`xterm-bg-${o.INVERTED_DEFAULT_COLOR}`)):Q=b.background}switch(J||W.isDim()&&(J=l.color.multiplyOpacity(Q,.5)),V){case 16777216:case 33554432:W.isBold()&&q<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(q+=8),this._applyMinimumContrast(w,Q,b.ansi[q],W,J,void 0)||P.push(`xterm-fg-${q}`);break;case 50331648:const e=l.channels.toColor(q>>16&255,q>>8&255,255&q);this._applyMinimumContrast(w,Q,e,W,J,Z)||this._addStyle(w,`color:#${q.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(w,Q,b.foreground,W,J,Z)||G&&P.push(`xterm-fg-${o.INVERTED_DEFAULT_COLOR}`)}P.length&&(w.className=P.join(" "),P.length=0),K||H||j||!N?w.textContent=E:D++,k!==this.defaultSpacing&&(w.style.letterSpacing=`${k}px`),m.push(w),I=F}return w&&D&&(w.textContent=E),m}_applyMinimumContrast(e,t,i,s,r,o){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const n=this._getContrastCache(s);let a;if(r||o||(a=n.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=l.color.ensureContrastRatio(r??t,o??i,e),n.setColor((r??t).rgba,(o??i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};t.DomRendererRowFactory=f,t.DomRendererRowFactory=f=s([r(1,c.ICharacterJoinerService),r(2,h.IOptionsService),r(3,c.ICoreBrowserService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,c.IThemeService)],f)},2744(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0;const s=i(6181);t.WidthCache=class{constructor(e=()=>new r){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let o=this._holey.get(r);if(void 0===o){let s=0;t&&(s|=1),i&&(s|=2),o=this._measure(e,s),o>0&&this._holey.set(r,o)}return o}_measure(e,t){return this._canvasElements[t].measure(e)}};class r{constructor(){"undefined"!=typeof OffscreenCanvas?(this._canvas=new OffscreenCanvas(1,1),this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d")))}setFont(e,t,i,s){const r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}}},9176(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181(e,t){function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,o){return 1===t&&r>Math.ceil(1.5*o)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,o=t[1]-r,n=i[1]-r,a=Math.max(o,0),h=Math.min(n,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},654(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.TextBlinkStateManager=void 0;const s=i(4812);class r extends s.Disposable{constructor(e,t,i){super(),this._renderCallback=e,this._coreBrowserService=t,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",e=>{this.setIntervalDuration(e)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const e=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(e||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}t.TextBlinkStateManager=r},8501(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._domNodePointerDown(e)))}_createArrow(e){const t=this._register(new c.ScrollbarArrow(e));return this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode),t}_createSlider(e,t,i,s){this.slider=new h.FastDomNode(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(a.addDisposableListener(this.slider.domNode,a.eventType.POINTER_DOWN,e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))})),this._onclick(this.slider.domNode,e=>{e.leftButton&&e.stopPropagation()})}_handleElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._handlePointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._handlePointerDown(e)}_handlePointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=a.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{const r=this._sliderOrthogonalPointerPosition(e),o=Math.abs(r-i);if(u.isWindows&&o>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const n=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(n))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=f},1203(e,t){function i(e){return"number"==typeof e?`${e}px`:e}Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.FastDomNode=class{constructor(e){this.domNode=e,this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._className="",this._position="",this._layerHint=!1,this._contain="none"}setWidth(e){const t=i(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=i(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=i(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=i(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=i(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=i(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=e?"translate3d(0px, 0px, 0px)":"")}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}}},928(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{try{e.releasePointerCapture(t)}catch{}}))}catch{o=a.getWindow(e)}this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_MOVE,e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)})),this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_UP,e=>this.stopMonitoring(!0)))}}},9699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.horizontalScrollbarSize:0,2===t.horizontal?0:t.horizontalScrollbarSize,2===t.vertical?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"xterm-horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._handleElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(2===e.horizontal?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},3988(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;ni&&(s=i-t),s<0&&(s=0),r<0&&(r=0),n+r>o&&(n=o-r),n<0&&(n=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=o,this.scrollTop=n}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,o=this.height!==e.height,n=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:o,scrollHeightChanged:n,scrollTopChanged:a}}}t.ScrollState=o;class n extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0!==this._smoothScrollDuration){if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new l(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):l.start(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=l.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}else this.setScrollPositionNow(e)}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=n;class a{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}class l{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this._scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this._scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let n,a;return e0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),o=Math.abs(t.deltaX),n=Math.abs(t.deltaY),a=Math.max(Math.min(s,o),1),h=Math.max(Math.min(r,n),1),l=Math.max(s,o),c=Math.max(r,n);l%a===0&&c%h===0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}S.INSTANCE=new S;class b extends _.Widget{get options(){return this._options}constructor(e,t,i){let s;super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,t=t??{};const r=!i;i?s=i:(t.mouseWheelSmoothScroll=!1,s=new g.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)})),this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:1,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:1,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,v.isMac&&(t.className+=" xterm-mac"),t}(t),this._scrollable=s,this._register(this._scrollable.onScroll(e=>{this._handleScroll(e),this._onScroll.fire(e)})),r&&this._register(this._scrollable);const o={handleMouseWheel:e=>this._handleMouseWheel(e),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,o)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,o)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,e=>this._handleMouseOver(e)),this._onmouseleave(this._listenOnDomNode,e=>this._handleMouseLeave(e)),this._hideTimeout=this._register(new u.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,v.isMac&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalHasArrows&&(this._options.horizontalHasArrows=e.horizontalHasArrows),void 0!==e.verticalHasArrows&&(this._options.verticalHasArrows=e.verticalHasArrows),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new l.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._handleMouseWheel(new l.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.eventType.MOUSE_WHEEL,e,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const o=!v.isMac&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!o||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const n=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=n.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=n.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(n.scrollLeft!==a.scrollLeft||n.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" xterm-shadow-left":"",r=t?" xterm-shadow-top":"",o=i||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${r}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${o}${r}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet(()=>this._hide(),500)}}t.SmoothScrollableElement=b},9594(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._arrowPointerDown(e))),this._register(c.addStandardDisposableListener(this.domNode,c.eventType.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._pointerdownRepeatTimer=this._register(new c.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,c.getWindow(e))},200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault())}}t.ScrollbarArrow=d},1270(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,o){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setArrowSize(e){const t=Math.round(e);this._arrowSize!==t&&(this._arrowSize=t,this._refreshComputedValues())}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const o=Math.max(0,i-e),n=Math.max(0,o-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(n),computedSliderRatio:0,computedSliderPosition:0};const h=Math.round(Math.max(20,Math.floor(i*n/s))),l=(n-h)/(s-i),c=r*l;return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(h),computedSliderRatio:l,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" xterm-fade":"")))}}t.ScrollbarVisibilityController=o},2650(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{s||(s=!0,this._remove(i))}}_remove(e){if(e.prev!==_.Undefined&&e.next!==_.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===_.Undefined&&e.next===_.Undefined?(this._first=_.Undefined,this._last=_.Undefined):e.next===_.Undefined?(this._last=this._last.prev,this._last.next=_.Undefined):e.prev===_.Undefined&&(this._first=this._first.next,this._first.prev=_.Undefined)}*[Symbol.iterator](){let e=this._first;for(;e!==_.Undefined;)yield e.element,e=e.next}}var f;!function(e){e.TAP="-xterm-gesturetap",e.CHANGE="-xterm-gesturechange",e.START="-xterm-gesturestart",e.END="-xterm-gesturesend",e.CONTEXT_MENU="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends l.Disposable{constructor(){super(),this._dispatched=!1,this._targets=new u,this._ignoreTargets=new u,this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;const e=c;this._register(h.addDisposableListener(e.document,"touchstart",e=>this._handleTouchStart(e),{passive:!1})),this._register(h.addDisposableListener(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(h.addDisposableListener(e.document,"touchmove",e=>this._handleTouchMove(e),{passive:!1}))}static addTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._targets.push(e);return(0,l.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._ignoreTargets.push(e);return(0,l.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in c||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){const t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let i=0,s=e.targetTouches.length;i=p._holdDelay&&Math.abs(n.initialPageX-d(n.rollingPageX))<30&&Math.abs(n.initialPageY-d(n.rollingPageY))<30){const e=this._newGestureEvent(f.CONTEXT_MENU,n.initialTarget);e.pageX=d(n.rollingPageX),e.pageY=d(n.rollingPageY),this._dispatchEvent(e)}else if(1===s){const t=d(n.rollingPageX),s=d(n.rollingPageY),r=d(n.rollingTimestamps)-n.rollingTimestamps[0],o=t-n.rollingPageX[0],a=s-n.rollingPageY[0],h=[...this._targets].filter(e=>n.initialTarget instanceof Node&&e.contains(n.initialTarget));this._inertia(e,h,i,Math.abs(o)/r,o>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this._dispatchEvent(this._newGestureEvent(f.END,n.initialTarget)),delete this._activeTouches[o.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}_dispatchEvent(e){if(e.type===f.TAP){const t=(new Date).getTime();let i;i=t-this._lastSetTapCountTime>p._clearTapCountTime?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.CHANGE&&e.type!==f.CONTEXT_MENU||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this._ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this._targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort((e,t)=>e[0]-t[0]);for(const[,i]of t)i.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,i,s,r,o,n,a,l){this._handle=h.scheduleAtNextAnimationFrame(e,()=>{const h=Date.now(),c=h-i;let d=0,_=0,u=!0;s+=p._scrollFriction*c,n+=p._scrollFriction*c,s>0&&(u=!1,d=r*s*c),n>0&&(u=!1,_=a*n*c);const v=this._newGestureEvent(f.CHANGE);v.translationX=d,v.translationY=_,t.forEach(e=>e.dispatchEvent(v)),u||this._inertia(e,t,h,s,r,o+d,n,a,l+_)})}_handleTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}}t.Gesture=p,p._scrollFriction=-.005,p._holdDelay=700,p._clearTapCountTime=400,n([function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r||!s)throw new Error("not supported");const o=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(o)||Object.defineProperty(this,o,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[o]}}],p,"isTouchDevice",null)},8997(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition(),n=t.verticalHasArrows;super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(n?t.verticalScrollbarSize:0,2===t.vertical?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"xterm-vertical",scrollable:e,scrollByPage:t.scrollByPage}),this._arrowScrollDelta=0,this._setArrows(n,t.verticalScrollbarSize),this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._handleElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}_arrowScroll(e){const t=this._scrollable.getCurrentScrollPosition();this._scrollable.setScrollPositionNow({scrollTop:t.scrollTop+e})}_setArrows(e,t){if(this._arrowScrollDelta=t,!this._arrowUp||!this._arrowDown){const e=0;this._arrowUp=this._createArrow({className:"xterm-scra xterm-arrow-up",top:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})}if(this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;const i=e?"":"none";this._arrowUp.bgDomNode.style.display=i,this._arrowUp.domNode.style.display=i,this._arrowDown.bgDomNode.style.display=i,this._arrowDown.domNode.style.display=i}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){const t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},7741(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nt(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseover(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_OVER,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseleave(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_LEAVE,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}}t.Widget=c},5959(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):!!(this.selectionStart&&this.selectionStart[1]<0)&&(this.selectionStart=[0,0],!0)}}},4792(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const o=i(6501),n=i(4812),a=i(8636);let h=class extends n.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h,t.CharSizeService=h=s([r(2,o.IOptionsService)],h);class l extends n.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends l{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends l{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},o=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(5451),a=i(8938),h=i(3055),l=i(6501);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new h.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;tthis._screenDprMonitor.setWindow(e))),this._register(s.EventUtils.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",()=>this._isFocused=!0)),this._register((0,r.addDisposableListener)(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}}t.CoreBrowserService=n;class a extends o.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new o.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,o.toDisposable)(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},2136(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.KeyboardService=void 0;const o=i(706),n=i(7241),a=i(9249),h=i(701),l=i(6501);let c=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??=new a.Win32InputMode,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new n.KittyKeyboard,this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);const t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):(0,o.evaluateKeyboardEvent)(e,this._coreService.decPrivateModes.applicationCursorKeys,h.isMac,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);const t=this._coreService.kittyKeyboard.flags;return this.useKitty&&2&t?this._getKittyKeyboard().evaluate(e,t,3,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):void 0}get useKitty(){const e=this._coreService.kittyKeyboard.flags;return!(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard||!n.KittyKeyboard.shouldUseProtocol(e))}get useWin32InputMode(){return!(!this._optionsService.rawOptions.vtExtensions?.win32InputMode||!this._coreService.decPrivateModes.win32InputMode)}};t.KeyboardService=c,t.KeyboardService=c=s([r(0,l.ICoreService),r(1,l.IOptionsService)],c)},9820(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(4812);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8294(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseCoordsService=void 0;const o=i(4159),n=i(5251),a=i(7098);let h=class{constructor(e,t){this._charSizeService=e,this._renderService=t}getCoords(e,t,i,s,r){return(0,n.getCoords)((0,o.getWindow)(t),e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,n.getCoordsRelativeToElement)((0,o.getWindow)(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseCoordsService=h,t.MouseCoordsService=h=s([r(0,a.ICharSizeService),r(1,a.IRenderService)],h)},9784(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AltMouseCursorController=t.MouseService=void 0;const o=i(4159),n=i(6501),a=i(4812),h=i(7098),l=i(2650);let c=class{constructor(e,t,i,s,r,o,n,a,h){this._renderService=e,this._mouseCoordsService=t,this._mouseStateService=i,this._coreService=s,this._bufferService=r,this._optionsService=o,this._selectionService=n,this._logService=a,this._coreBrowserService=h,this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}bindMouse(e,t,i){const{element:s,document:r}=e,n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},h={target:e,focus:i,requestedEvents:n},c={mouseup:e=>this._handleMouseUp(h,e),wheel:e=>this._handleWheel(h,e),mousedrag:e=>this._handleMouseDrag(h,e),mousemove:e=>this._handleMouseMove(h,e)};this._altMouseCursor=new d(s,r,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),t(this._altMouseCursor),t(this._mouseStateService.onProtocolChange(e=>{this._handleProtocolChange(h,c,e)})),t(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(s),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,t((0,a.toDisposable)(()=>{n.mouseup&&r.removeEventListener("mouseup",n.mouseup),n.mousedrag&&r.removeEventListener("mousemove",n.mousedrag)})),t((0,o.addDisposableListener)(s,"mousedown",e=>this._handleMouseDown(h,e))),t((0,o.addDisposableListener)(s,"wheel",e=>this._handlePassiveWheel(h,e),{passive:!1})),t(l.Gesture.addTarget(e.screenElement)),t((0,o.addDisposableListener)(e.screenElement,l.EventType.START,()=>this._handleTouchStart())),t((0,o.addDisposableListener)(e.screenElement,l.EventType.CHANGE,e=>this._handleTouchChange(h,e)))}_sendEvent(e,t){const i=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;const e=t.deltaY;if(0===e)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return!1;r=e<0?0:1,s=4;break;default:return!1}if(void 0===r||void 0===s||s>4)return!1;if(4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!t.altKey)return!1;const o=4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:!o&&t.altKey,shift:t.shiftKey})}_handleMouseUp(e,t){this._sendEvent(e,t),t.buttons||(e.requestedEvents.mouseup&&e.target.document.removeEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.removeEventListener("mousemove",e.requestedEvents.mousedrag))}_handleWheel(e,t){return this._sendEvent(e,t),t.preventDefault(),t.stopPropagation(),!1}_handleMouseDrag(e,t){t.buttons&&this._sendEvent(e,t)}_handleMouseMove(e,t){t.buttons||this._sendEvent(e,t)}_handleMouseDown(e,t){t.preventDefault(),e.focus(),this._mouseStateService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(t)&&(this._sendEvent(e,t),e.requestedEvents.mouseup&&e.target.document.addEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.addEventListener("mousemove",e.requestedEvents.mousedrag))}_handlePassiveWheel(e,t){if(!e.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;if(!this._bufferService.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return t.preventDefault(),t.stopPropagation(),!1;const e=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(e,!0),t.preventDefault(),t.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(e,t){t.preventDefault(),t.stopPropagation(),e.requestedEvents.wheel?this._handleTouchScrollAsWheel(e,t):this._bufferService.buffer.hasScrollback?e.target.handleTouchScroll?.(t.translationY):this._handleTouchScrollAsKeys(t)}_handleTouchScrollAsKeys(e){const t=this._renderService?.dimensions.css.cell.height;if(!t)return;this._touchScrollAccumulator-=e.translationY;const i=Math.trunc(this._touchScrollAccumulator/t);if(0===i)return;this._touchScrollAccumulator-=i*t;const s=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(i<0?"A":"B");for(let e=0;e0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._mouseStateService.isPixelEncoding))return!1;if(!this._mouseStateService.restrictMouseEvent(e))return!1;const t=this._mouseStateService.encodeMouseEvent(e);return t&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}_explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.MouseService=c,t.MouseService=c=s([r(0,h.IRenderService),r(1,h.IMouseCoordsService),r(2,n.IMouseStateService),r(3,n.ICoreService),r(4,n.IBufferService),r(5,n.IOptionsService),r(6,h.ISelectionService),r(7,n.ILogService),r(8,h.ICoreBrowserService)],c);class d{constructor(e,t,i){this._element=e,this._document=t,this._isActive=i,this._listeners=new a.MutableDisposable}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;const e=new a.DisposableStore,t=e=>this.syncFromModifier(e);e.add((0,o.addDisposableListener)(this._document,"keydown",t)),e.add((0,o.addDisposableListener)(this._document,"keyup",t)),e.add((0,o.addDisposableListener)(this._element,"mousemove",t));const i=this._element.ownerDocument?.defaultView;i&&e.add((0,o.addDisposableListener)(i,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=e}resetClass(){this._updateClass(!1)}syncFromModifier(e){this._isActive()&&this._updateClass(e.getModifierState("Alt"))}_updateClass(e){e?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}}t.AltMouseCursorController=d},5783(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const o=i(4852),n=i(7098),a=i(4812),h=i(6168),l=i(6501),c=i(8636);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,n,l,d,u,f){super(),this._rowCount=e,this._optionsService=i,this._logService=s,this._charSizeService=r,this._coreService=n,this._coreBrowserService=u,this._renderer=this._register(new a.MutableDisposable),this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._pausedResizeTask=this._register(new h.DebouncedIdleTask(this._logService)),this._renderDebouncer=new o.RenderDebouncer((e,t)=>this._renderRows(e,t),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new _(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register((0,a.toDisposable)(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(d.onResize(()=>this._fullRefresh())),this._register(d.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(d.cols,d.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(d.buffer.y,d.buffer.y,void 0,!0))),this._register(f.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(e=>this._registerIntersectionObserver(e,t)))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver(e=>this._handleIntersectionChange(e[e.length-1]),{threshold:0});this._observerDisposable.value=(0,a.toDisposable)(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=i,i.observe(t)}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(e=>this.refreshRows(e.start,e.end,e.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,l.IOptionsService),r(3,l.ILogService),r(4,n.ICharSizeService),r(5,l.ICoreService),r(6,l.IDecorationService),r(7,l.IBufferService),r(8,n.ICoreBrowserService),r(9,n.IThemeService)],d);class _{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(e=>this._handleTrim(e)),this._register(this._bufferService.buffers.onBufferActivate(e=>this._handleBufferActivate(e))),this.enable(),this._model=new d.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,u.toDisposable)(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(e=>{e.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(b," ")).join(f.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),f.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:f.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(2===e.button&&this.hasSelection||0!==e.button||this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){const t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);const i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return(!this._optionsService.rawOptions.mouseEventsRequireAlt||!this._mouseStateService.areMouseEventsActive)&&e.altKey&&!(f.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,c.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(e=>this._handleTrim(e))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,o=r.lines.get(e[1]);if(!o)return;const n=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(o,e[0]),h=a;const l=e[0]-a;let c=0,d=0,_=0,u=0;if(" "===n.charAt(a)){for(;a>0&&" "===n.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(o.loadCell(t-1,this._workCell));){o.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+l-c+_,p=Math.min(this._bufferService.cols,h-a+c+d-_-u);if(t||""!==n.slice(a,h).trim()){if(i&&0===f&&32!==o.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&o.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==o.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=w,t.SelectionService=w=n([h(3,g.IBufferService),h(4,g.ICoreService),h(5,_.IMouseCoordsService),h(6,g.IOptionsService),h(7,g.IMouseStateService),h(8,_.IRenderService),h(9,_.ICoreBrowserService)],w)},7098(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IKeyboardService=t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.IMouseCoordsService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseCoordsService=(0,s.createDecorator)("MouseCoordsService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService"),t.IKeyboardService=(0,s.createDecorator)("KeyboardService")},9078(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const o=i(7174),n=i(9302),a=i(4103),h=i(4812),l=i(6501),c=i(8636),d=a.css.toColor("#ffffff"),_=a.css.toColor("#000000"),u=a.css.toColor("#ffffff"),f=_,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},v=d;let g=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new o.ColorContrastCache,this._halfContrastCache=new o.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:_,cursor:u,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(_,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(_,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:n.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}_setTheme(e={}){const t=this._colors;if(t.foreground=m(e.foreground,d),t.background=m(e.background,_),t.cursor=a.color.blend(t.background,m(e.cursor,u)),t.cursorAccent=a.color.blend(t.background,m(e.cursorAccent,f)),t.selectionBackgroundTransparent=m(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=m(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?m(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=m(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=m(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=m(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=m(e.overviewRulerBorder,v),t.ansi=n.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=m(e.black,n.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=m(e.red,n.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=m(e.green,n.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=m(e.yellow,n.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=m(e.blue,n.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=m(e.magenta,n.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=m(e.cyan,n.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=m(e.white,n.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=m(e.brightBlack,n.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=m(e.brightRed,n.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=m(e.brightGreen,n.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=m(e.brightYellow,n.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=m(e.brightBlue,n.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=m(e.brightMagenta,n.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=m(e.brightCyan,n.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=m(e.brightWhite,n.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;ssetTimeout(t,e))},t.disposableTimeout=function(e,t=0,i){const r=setTimeout(()=>{e(),i&&o.dispose()},t),o=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(o),o};const s=i(4812);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{e()},t);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},5639(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(4812),r=i(8636);class o extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(n||(t.channels=n={})),function(e){function t(e,t){return o=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:n.toCss(i,s,r,o),rgba:n.toRgba(i,s,r,o)}}e.blend=function(e,t){if(o=(255&t.rgba)/255,1===o)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,l=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),{css:n.toCss(i,s,r),rgba:n.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return n.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:n.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return o=255&e.rgba,t(e,o*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o=parseInt(e.slice(4,5).repeat(2),16),n.toColor(i,s,r,o);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1],10),s=parseInt(h[2],10),r=parseInt(h[3],10),o=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),n.toColor(i,s,r,o);if("transparent"===e)return{css:"transparent",rgba:0};if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,o]=t.getImageData(0,0,1,1).data,255!==o)throw new Error("css.toColor: Unsupported css format");return{rgba:n.toRgba(i,s,r,o),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,o=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(l||(t.rgb=l={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c0||a>0||h>0);)n-=Math.max(0,Math.ceil(.1*n)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));return(n<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c>>0}e.blend=function(e,t){if(o=(255&t)/255,1===o)return t;const a=t>>24&255,h=t>>16&255,l=t>>8&255,c=e>>24&255,d=e>>16&255,_=e>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),n.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=l.relativeLuminance(e>>8),o=l.relativeLuminance(i>>8);if(_(r,o)>8));if(n_(r,l.relativeLuminance(t>>8))?o:t}return o}const n=a(e,i,s),h=_(r,l.relativeLuminance(n>>8));if(h_(r,l.relativeLuminance(o>>8))?n:o}return n}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),o=i(7276),n=i(9640),a=i(56),h=i(4071),l=i(6478),c=i(7428),d=i(6415),_=i(5746),u=i(5882),f=i(2486),p=i(3562),v=i(8811),g=i(8636),m=i(4812);let S=!1;class b extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new g.Emitter),this.onRender=this._onRender.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(n.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(l.MouseStateService)),this._instantiationService.setService(s.IMouseStateService,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(d.UnicodeService)),this.unicodeService.register(new c.UnicodeV6),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(_.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(v.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new f.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(g.EventUtils.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(g.EventUtils.forward(this._bufferService.onResize,this._onResize)),this._register(g.EventUtils.forward(this.coreService.onData,this._onData)),this._register(g.EventUtils.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new p.WriteBuffer((e,t)=>this._inputHandler.parse(e,t))),this._register(g.EventUtils.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!S&&(this._logService.warn("writeSync is unreliable and will be removed soon."),S=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.backend&&void 0!==t.buildNumber&&(e=!!("conpty"===t.backend&&t.buildNumber<21376)),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)(()=>{for(const t of e)t.dispose()})}}}t.CoreTerminal=b},8636(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const s=i(4812);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners.push(r);const o=(0,s.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&this._listeners.splice(e,1)});return i&&(Array.isArray(i)?i.push(o):i.add(o)),o}),this._event}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:t,thisArgs:i}=this._listeners[0];return void t.call(i,e)}default:{const t=this._listeners.slice();for(const{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(e){e.forward=function(e,t){return e(e=>t.fire(e))},e.map=function(e,t){return(i,s,r)=>e(e=>i.call(s,t(e)),void 0,r)},e.any=function(...e){return(t,i,r)=>{const o=new s.DisposableStore;for(const s of e)o.add(s(e=>t.call(i,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(r||(t.EventUtils=r={}))},2486(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=L;const o=i(6760),n=i(6717),a=i(4812),h=i(726),l=i(6107),c=i(8938),d=i(3055),_=i(5451),u=i(6501),f=i(6415),p=i(1346),v=i(9823),g=i(2607),m=i(8693),S=i(8636),b=i(7804),w={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var C;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(C||(t.WindowsOptionsReportType=C={}));let k=0;class D extends a.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,a,c,d,_=new n.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=a,this._mouseStateService=c,this._unicodeService=d,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new S.Emitter),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new E(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(e=>this._activeBuffer=e.activeBuffer)),this._parser.setCsiHandlerFallback((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})}),this._parser.setEscHandlerFallback(e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})}),this._parser.setExecuteHandlerFallback(e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})}),this._parser.setOscHandlerFallback((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})}),this._parser.setDcsHandlerFallback((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setApcHandlerFallback((e,t,i)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setPrintHandler((e,t,i)=>this.print(e,t,i)),this._parser.registerCsiHandler({final:"@"},e=>this.insertChars(e)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},e=>this.scrollLeft(e)),this._parser.registerCsiHandler({final:"A"},e=>this.cursorUp(e)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},e=>this.scrollRight(e)),this._parser.registerCsiHandler({final:"B"},e=>this.cursorDown(e)),this._parser.registerCsiHandler({final:"C"},e=>this.cursorForward(e)),this._parser.registerCsiHandler({final:"D"},e=>this.cursorBackward(e)),this._parser.registerCsiHandler({final:"E"},e=>this.cursorNextLine(e)),this._parser.registerCsiHandler({final:"F"},e=>this.cursorPrecedingLine(e)),this._parser.registerCsiHandler({final:"G"},e=>this.cursorCharAbsolute(e)),this._parser.registerCsiHandler({final:"H"},e=>this.cursorPosition(e)),this._parser.registerCsiHandler({final:"I"},e=>this.cursorForwardTab(e)),this._parser.registerCsiHandler({final:"J"},e=>this.eraseInDisplay(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},e=>this.eraseInDisplay(e,!0)),this._parser.registerCsiHandler({final:"K"},e=>this.eraseInLine(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},e=>this.eraseInLine(e,!0)),this._parser.registerCsiHandler({final:"L"},e=>this.insertLines(e)),this._parser.registerCsiHandler({final:"M"},e=>this.deleteLines(e)),this._parser.registerCsiHandler({final:"P"},e=>this.deleteChars(e)),this._parser.registerCsiHandler({final:"S"},e=>this.scrollUp(e)),this._parser.registerCsiHandler({final:"T"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"X"},e=>this.eraseChars(e)),this._parser.registerCsiHandler({final:"Z"},e=>this.cursorBackwardTab(e)),this._parser.registerCsiHandler({final:"^"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"`"},e=>this.charPosAbsolute(e)),this._parser.registerCsiHandler({final:"a"},e=>this.hPositionRelative(e)),this._parser.registerCsiHandler({final:"b"},e=>this.repeatPrecedingCharacter(e)),this._parser.registerCsiHandler({final:"c"},e=>this.sendDeviceAttributesPrimary(e)),this._parser.registerCsiHandler({prefix:">",final:"c"},e=>this.sendDeviceAttributesSecondary(e)),this._parser.registerCsiHandler({final:"d"},e=>this.linePosAbsolute(e)),this._parser.registerCsiHandler({final:"e"},e=>this.vPositionRelative(e)),this._parser.registerCsiHandler({final:"f"},e=>this.hVPosition(e)),this._parser.registerCsiHandler({final:"g"},e=>this.tabClear(e)),this._parser.registerCsiHandler({final:"h"},e=>this.setMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"h"},e=>this.setModePrivate(e)),this._parser.registerCsiHandler({final:"l"},e=>this.resetMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"l"},e=>this.resetModePrivate(e)),this._parser.registerCsiHandler({final:"m"},e=>this.charAttributes(e)),this._parser.registerCsiHandler({final:"n"},e=>this.deviceStatus(e)),this._parser.registerCsiHandler({prefix:"?",final:"n"},e=>this.deviceStatusPrivate(e)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},e=>this.softReset(e)),this._parser.registerCsiHandler({prefix:">",final:"q"},e=>this.sendXtVersion(e)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},e=>this.setCursorStyle(e)),this._parser.registerCsiHandler({final:"r"},e=>this.setScrollRegion(e)),this._parser.registerCsiHandler({final:"s"},e=>this.saveCursor(e)),this._parser.registerCsiHandler({final:"t"},e=>this.windowOptions(e)),this._parser.registerCsiHandler({final:"u"},e=>this.restoreCursor(e)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},e=>this.insertColumns(e)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},e=>this.deleteColumns(e)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},e=>this.selectProtected(e)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},e=>this.requestMode(e,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},e=>this.requestMode(e,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},e=>this.kittyKeyboardSet(e)),this._parser.registerCsiHandler({prefix:"?",final:"u"},e=>this.kittyKeyboardQuery(e)),this._parser.registerCsiHandler({prefix:">",final:"u"},e=>this.kittyKeyboardPush(e)),this._parser.registerCsiHandler({prefix:"<",final:"u"},e=>this.kittyKeyboardPop(e)),this._parser.setExecuteHandler("",()=>this.bell()),this._parser.setExecuteHandler("\n",()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler("\t",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("„",()=>this.index()),this._parser.setExecuteHandler("…",()=>this.nextLine()),this._parser.setExecuteHandler("ˆ",()=>this.tabSet()),this._parser.registerOscHandler(0,new p.OscHandler(e=>(this.setTitle(e),this.setIconName(e),!0))),this._parser.registerOscHandler(1,new p.OscHandler(e=>this.setIconName(e))),this._parser.registerOscHandler(2,new p.OscHandler(e=>this.setTitle(e))),this._parser.registerOscHandler(4,new p.OscHandler(e=>this.setOrReportIndexedColor(e))),this._parser.registerOscHandler(8,new p.OscHandler(e=>this.setHyperlink(e))),this._parser.registerOscHandler(10,new p.OscHandler(e=>this.setOrReportFgColor(e))),this._parser.registerOscHandler(11,new p.OscHandler(e=>this.setOrReportBgColor(e))),this._parser.registerOscHandler(12,new p.OscHandler(e=>this.setOrReportCursorColor(e))),this._parser.registerOscHandler(104,new p.OscHandler(e=>this.restoreIndexedColor(e))),this._parser.registerOscHandler(110,new p.OscHandler(e=>this.restoreFgColor(e))),this._parser.registerOscHandler(111,new p.OscHandler(e=>this.restoreBgColor(e))),this._parser.registerOscHandler(112,new p.OscHandler(e=>this.restoreCursorColor(e))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},()=>this.selectCharset("("+e)),this._parser.registerEscHandler({intermediates:")",final:e},()=>this.selectCharset(")"+e)),this._parser.registerEscHandler({intermediates:"*",final:e},()=>this.selectCharset("*"+e)),this._parser.registerEscHandler({intermediates:"+",final:e},()=>this.selectCharset("+"+e)),this._parser.registerEscHandler({intermediates:"-",final:e},()=>this.selectCharset("-"+e)),this._parser.registerEscHandler({intermediates:".",final:e},()=>this.selectCharset("."+e)),this._parser.registerEscHandler({intermediates:"/",final:e},()=>this.selectCharset("/"+e));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(e=>(this._logService.error("Parsing error: ",e),e)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new v.DcsHandler((e,t)=>this.requestStatusString(e,t)))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=u.LogLevelEnum.WARN){let t;const i=new Promise((e,i)=>{t=setTimeout(()=>i("#SLOW_TIMEOUT"),5e3)});Promise.race([e,i]).then(()=>{void 0!==t&&clearTimeout(t)},e=>{if(void 0!==t&&clearTimeout(t),"#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,o=0;const n=this._parseStack.paused;if(n){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(o=this._parseStack.position+131072)}if(this._logService.logLevel<=u.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,e=>String.fromCharCode(e)).join("")}"`)),this._logService.logLevel===u.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map(e=>e.charCodeAt(0)):e),this._parseBuffer.length131072)for(let t=o;t0&&2===p.getWidth(this._activeBuffer.x-1)&&p.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(d){const e=p;let t=this._activeBuffer.x-m;if(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),p=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!p)return;for(m>0&&p instanceof l.BufferLine&&p.copyCellsFrom(e,t,0,m,!1);t=0;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(_&&(p.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===p.getWidth(a-1)&&p.setCellFromCodepoint(a-1,c.NULL_CELL_CODE,c.NULL_CELL_WIDTH,u)),p.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===p.getWidth(this._activeBuffer.x)&&!p.hasContent(this._activeBuffer.x)&&p.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new v.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new g.ApcHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const o=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);o&&(o.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(o.isWrapped=!1))}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols){const e=this._activeBuffer.lines.get(i+1);e&&(e.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`P>|xterm.js(${b.XTERM_VERSION})\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(o.triggerDataEvent(`[${t?"":"?"}${e};${i}$y`),!0),_=e=>e?1:2,u=e.params[0];return t?d(u,2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(c.convertEol):0):1===u?d(u,_(i.applicationCursorKeys)):3===u?d(u,c.windowOptions.setWinLines?80===a?2:132===a?1:0:0):6===u?d(u,_(i.origin)):7===u?d(u,_(i.wraparound)):8===u?d(u,3):9===u?d(u,_("X10"===s)):12===u?d(u,_(c.cursorBlink)):25===u?d(u,_(!o.isCursorHidden)):45===u?d(u,_(i.reverseWraparound)):66===u?d(u,_(i.applicationKeypad)):67===u?d(u,4):1e3===u?d(u,_("VT200"===s)):1002===u?d(u,_("DRAG"===s)):1003===u?d(u,_("ANY"===s)):1004===u?d(u,_(i.sendFocus)):1005===u?d(u,4):1006===u?d(u,_("SGR"===r)):1015===u?d(u,4):1016===u?d(u,_("SGR_PIXELS"===r)):1048===u?d(u,1):47===u||1047===u||1049===u?d(u,_(h===l)):2004===u?d(u,_(i.bracketedPasteMode)):2026===u?d(u,_(i.synchronizedOutput)):9001===u&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?d(u,_(i.win32InputMode)):d(u,0)}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-67108864,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,o=0;do{if(s[o+r]=e.params[t+o],e.hasSubParams(t+o)){const i=e.getSubParams(t+o);let n=0;do{5===s[1]&&(r=1),s[o+n+1+r]=i[n]}while(++n=2||2===s[1]&&o+r>=5)break;s[1]&&(r=1)}while(++o+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-67108864,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-67108864,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-67108864,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-67108864,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:221===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.fg&=-134217729:222===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.bg&=-134217729:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("");break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[${e};${t}R`)}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[?${e};${t}R`);break;case 15:case 25:case 26:case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??1)&&this._onRequestColorSchemeQuery.fire()}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(C.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(C.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let e=0;e1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e,10);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex(e=>e.startsWith("id="));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new d.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${e}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let e=0;e0;e++)i.flags=s.pop();return 0===s.length&&t>0&&(i.flags=0),!0}}t.InputHandler=D;let E=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(k=e,e=t,t=k),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}E=s([r(0,u.IBufferService)],E)},4812(e,t){function i(e){return{dispose:e}}function s(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=s,t.combinedDisposable=function(...e){return i(()=>s(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},7710(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,o){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,o)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isMac=t.isSafari=t.isLegacyEdge=t.isChrome=t.isFirefox=t.isNode=void 0,t.getZoomFactor=function(e){return 1},t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1],10)},t.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isChrome=i.includes("Chrome"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e,t){this._getKey=e,this._array=[],this._insertedValues=[],this._isFlushingInserted=!1,this._deletedIndices=[],this._isFlushingDeleted=!1,this._flushInsertedTask=new s.IdleTaskQueue(t),this._flushDeletedTask=new s.IdleTaskQueue(t)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort((e,t)=>this._getKey(e)-this._getKey(t));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(r),!0}while(++re-t);let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},4220(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.LimitedStringBuilder=t.StringBuilder=void 0;class i{constructor(){this._chunks=[],this._length=0}get length(){return this._length}reset(){this._chunks.length=0,this._length=0}append(e){this._chunks.push(e),this._length+=e.length}toString(){return this._chunks.join("")}}t.StringBuilder=i,t.LimitedStringBuilder=class{constructor(e){this._limit=e,this._builder=new i}get length(){return this._builder.length}get limit(){return this._limit}reset(){this._builder.reset()}append(e){return this._builder.append(e),this._builder.length>this._limit&&(this._builder.reset(),!0)}toString(){return this._builder.toString()}}},6168(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;class i{constructor(e){this._tasks=[],this._i=0,this._logService=e}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ii)return r-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=s,t.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:s,t.DebouncedIdleTask=class{constructor(e){this._queue=new t.IdleTaskQueue(e)}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},7804(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.XTERM_VERSION=void 0,t.XTERM_VERSION="6.1.0-beta.287"},5882(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(4812),o=i(6168),n=i(5451),a=i(6107),h=i(3326),l=i(732),c=i(3055),d=i(8938),_=i(8158),u=i(6760);t.MAX_BUFFER_SIZE=4294967295;class f extends r.Disposable{constructor(e,t,i,n){super(),this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this._logService=n,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=a.DEFAULT_ATTR_DATA.clone(),this.savedCharset=u.DEFAULT_CHARSET,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=c.CellData.fromCharData([0,d.NULL_CELL_CHAR,d.NULL_CELL_WIDTH,d.NULL_CELL_CODE]),this._whitespaceCell=c.CellData.fromCharData([0,d.WHITESPACE_CELL_CHAR,d.WHITESPACE_CELL_WIDTH,d.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new o.IdleTaskQueue(this._logService),this._register((0,r.toDisposable)(()=>this._memoryCleanupQueue.clear())),this._register((0,r.toDisposable)(()=>this.clearAllMarkers())),this._stringCache=this._register(new h.BufferLineStringCache)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new a.BufferLine(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){e??=a.DEFAULT_ATTR_DATA;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(a.DEFAULT_ATTR_DATA);this._stringCache.clear();let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+o+1?(this.ybase--,o++,this.ydisp>0&&this.ydisp--):this.lines.push(new a.BufferLine(this._stringCache,e,i,!1)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),o&&(this.y+=o),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t0){const e=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,e)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,l.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(a.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,l.reflowLargerCreateNewLayout)(this.lines,s);(0,l.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(a.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=n&&e0&&(r.push({start:n+c.length+o,newLines:p}),o+=p.length),c.push(...p);let v=_.length-1,g=_[v];0===g&&(v--,g=_[v]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[v])break;if(c[v].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(v--,g=_[v]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,l.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;l--)if(a&&a.start>s+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(l--,a.newLines[e]);l++,e.push({index:s+1,amount:a.newLines.length}),h+=a.newLines.length,a=r[++n]}else this.lines.set(l,t[s--]);let l=0;for(let t=e.length-1;t>=0;t--)e[t].index+=l,this.lines.onInsertEmitter.fire(e[t]),l+=e[t].amount;const c=Math.max(0,i+o-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(e=>{t.line>=e.index&&(t.line+=e.amount)})),t.register(this.lines.onDelete(e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}t.Buffer=f},6107(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),o=i(8938),n=i(726),a=i(4220);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let h=0;const l=new r.CellData,c=new a.StringBuilder;class d{constructor(e,t,i,s=!1){this._stringCache=e,this.isWrapped=s,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const n=i??r.CellData.fromCharData([0,o.NULL_CELL_CHAR,o.NULL_CELL_WIDTH,o.NULL_CELL_CODE]);for(let e=0;e>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._invalidateStringCache(),this._data[3*e+1]=t[o.CHAR_DATA_ATTR_INDEX],t[o.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[o.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[o.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[o.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,n.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,i){return h=3*e,i.content=this._data[h+0],i.fg=this._data[h+1],i.bg=this._data[h+2],2097152&i.content?i.combinedData=this._combined[e]:i.combinedData="",268435456&i.bg?i.extended=this._extendedAttrs[e]:i.extended=t.DEFAULT_ATTR_DATA.extended.clone(),i}setCell(e,t){this._invalidateStringCache(),2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){this._invalidateStringCache(),268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){this._invalidateStringCache();let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,n.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,n.stringFromCodePoint)(2097151&s)+(0,n.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if(this._invalidateStringCache(),(e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,l));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){this._invalidateStringCache();const o=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=o[3*(t+r)+e];this._copyCellMapsFrom(e,t+r,i+r)}else for(let r=0;r>22||1}s&&s.push(t);const h=c.toString();if(c.reset(),r){const t=this._getStringCacheEntry(!0);t.value=h,t.isTrimmed=!!e}return h}_getStringCacheEntry(e){const t=this._stringCacheEntryRef?.deref();if(t&&t.generation===this._stringCache.generation)return t;if(!e)return;const i=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(i),i}_invalidateStringCache(){const e=this._getStringCacheEntry(!1);e&&(e.value=void 0,e.isTrimmed=!1)}_copyCellMapsFrom(e,t,i){const s=3*t;2097152&e._data[s+0]&&(this._combined[i]=e._combined[t]),268435456&e._data[s+2]&&(this._extendedAttrs[i]=e._extendedAttrs[t])}_copySparseMapsFrom(e){this._combined={},this._extendedAttrs={};for(let t=0;tthis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){const e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(const e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),this._clearTimeout.value||this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=(0,s.disposableTimeout)(()=>{const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this.clear():this._scheduleClearTimeout(15e3-e)},e)}}t.BufferLineStringCache=o},9384(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732(e,t){function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,o,n){const a=[];for(let h=0;h=h&&r0&&(e>_||0===d[e].getTrimmedLength());e--)v++;v>0&&(a.push(h+d.length-v),a.push(v)),h+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],o=0;for(let n=0;nl&&(n-=l,a++);const c=2===e[a].getWidth(n-1);c&&n--;const d=c?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},4097(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(4812),r=i(1073),o=i(8636);class n extends s.Disposable{constructor(e,t,i){super(),this._optionsService=e,this._bufferService=t,this._logService=i,this._normalBuffer=this._register(new s.MutableDisposable),this._altBuffer=this._register(new s.MutableDisposable),this._onBufferActivate=this._register(new o.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=n},3055(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),o=i(5451);class n extends o.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new o.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new n;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor())return!1;if(this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor())return!1;if(this.isInverse()!==e.isInverse())return!1;if(this.isBold()!==e.isBold())return!1;if(this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;const t=this.isUnderlineColorDefault(),i=e.isUnderlineColorDefault();if(!t||!i){if(t!==i)return!1;if(this.getUnderlineColor()!==e.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==e.getUnderlineColorMode())return!1}}return this.isOverline()===e.isOverline()&&this.isBlink()===e.isBlink()&&this.isInvisible()===e.isInvisible()&&this.isItalic()===e.isItalic()&&this.isDim()===e.isDim()&&this.isStrikethrough()===e.isStrikethrough()}}t.CellData=n},8938(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(4812),r=i(8636);class o{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=o._nextId++,this._onDispose=this.register(new r.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,s.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=o,o._nextId=1},6760(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},706(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,s,r){const o={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?"OA":"":"UIKeyInputLeftArrow"===e.key?o.key=t?"OD":"":"UIKeyInputRightArrow"===e.key?o.key=t?"OC":"":"UIKeyInputDownArrow"===e.key&&(o.key=t?"OB":"");break;case 8:o.key=e.ctrlKey?"\b":"",e.altKey&&(o.key=""+o.key);break;case 9:if(e.shiftKey){o.key="";break}o.key="\t",o.cancel=!0;break;case 13:"c"===e.key&&e.ctrlKey?o.key="":o.key=e.altKey?"\r":"\r",o.cancel=!0;break;case 27:o.key="",e.altKey&&(o.key=""),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"D":t?"OD":"";break;case 39:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"C":t?"OC":"";break;case 38:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"A":t?"OA":"";break;case 40:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"B":t?"OB":"";break;case 45:e.shiftKey||e.ctrlKey||(o.key="[2~");break;case 46:o.key=n?"[3;"+(n+1)+"~":"[3~";break;case 36:o.key=n?"[1;"+(n+1)+"H":t?"OH":"";break;case 35:o.key=n?"[1;"+(n+1)+"F":t?"OF":"";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key="[5;"+(n+1)+"~":o.key="[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key="[6;"+(n+1)+"~":o.key="[6~";break;case 112:o.key=n?"[1;"+(n+1)+"P":"OP";break;case 113:o.key=n?"[1;"+(n+1)+"Q":"OQ";break;case 114:o.key=n?"[1;"+(n+1)+"R":"OR";break;case 115:o.key=n?"[1;"+(n+1)+"S":"OS";break;case 116:o.key=n?"[15;"+(n+1)+"~":"[15~";break;case 117:o.key=n?"[17;"+(n+1)+"~":"[17~";break;case 118:o.key=n?"[18;"+(n+1)+"~":"[18~";break;case 119:o.key=n?"[19;"+(n+1)+"~":"[19~";break;case 120:o.key=n?"[20;"+(n+1)+"~":"[20~";break;case 121:o.key=n?"[21;"+(n+1)+"~":"[21~";break;case 122:o.key=n?"[23;"+(n+1)+"~":"[23~";break;case 123:o.key=n?"[24;"+(n+1)+"~":"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(s&&!r||!e.altKey||e.metaKey)if(!s||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey){if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length)o.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":o.key="";break;case"Digit2":o.key="\0";break;case"Digit6":o.key=""}}else 65===e.keyCode&&(o.type=1);else{const t=i[e.keyCode],s=t?.[e.shiftKey?1:0];if(s)o.key=""+s;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=""+i}else if(32===e.keyCode)o.key=""+(e.ctrlKey?"\0":" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=""+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key="\0":e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key="":"/"===e.key?o.key="":219===e.keyCode?o.key="":220===e.keyCode?o.key="":221===e.keyCode&&(o.key="")}return o};const i={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},7241(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyKeyboard=void 0,t.KittyKeyboard=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){const t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e,t){const i=this._getNumpadKeyCode(e);if(void 0!==i)return i;const s=this._getModifierKeyCode(e);if(void 0!==s)return s;const r=this._functionalKeyCodes[e.key];if(void 0!==r)return r;if((e.shiftKey||t&&e.altKey)&&e.code){if(e.code.startsWith("Digit")&&6===e.code.length){const t=e.code.charAt(5);if(t>="0"&&t<="9")return t.charCodeAt(0)}if(e.code.startsWith("Key")&&4===e.code.length)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(1===e.key.length){const t=e.key.codePointAt(0);return t>=65&&t<=90?t+32:t}}_isModifierKey(e){return"Shift"===e.key||"Control"===e.key||"Alt"===e.key||"Meta"===e.key}_isLockKey(e){return"CapsLock"===e.key||"NumLock"===e.key||"ScrollLock"===e.key}_buildCsiLetterSequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"["+e}_buildSs3Sequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"O"+e}_buildCsiTildeSequence(e,t,i,s){const r=s&&1!==i;let o="["+e;return(t>0||r)&&(o+=";"+(t>0?t:"1"),r&&(o+=":"+i)),o+="~",o}_buildCsiUSequence(e,t,i,s,r,o,n){const a=!!(2&r);let h,l="["+t;4&r&&e.shiftKey&&1===e.key.length&&!o&&!n&&(h=e.key.codePointAt(0),l+=":"+h);const c=16&r&&3!==s&&1===e.key.length&&!o&&!n&&!e.ctrlKey?e.key.codePointAt(0):void 0,d=a&&1!==s&&(3===s||void 0===c);return(i>0||d||void 0!==c)&&(l+=";",i>0?l+=i:d&&(l+="1"),d&&(l+=":"+s)),void 0!==c&&(l+=";"+c),l+="u",l}evaluate(e,t,i=1,s=!1){const r={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(e),n=this._isModifierKey(e),a=!!(2&t);if(!a&&3===i)return r;if(n&&!(8&t))return r;if(this._isLockKey(e)&&!(8&t))return r;const h=this._csiLetterKeys[e.key];if(h)return r.key=this._buildCsiLetterSequence(h,o,i,a),r.cancel=!0,r;const l=this._ss3FunctionKeys[e.key];if(l)return r.key=this._buildSs3Sequence(l,o,i,a),r.cancel=!0,r;const c=this._csiTildeKeys[e.key];if(void 0!==c)return r.key=this._buildCsiTildeSequence(c,o,i,a),r.cancel=!0,r;const d=this._getKeyCode(e,s);if(void 0===d)return r;const _=13===d||9===d||127===d;if(_&&3===i&&!(8&t))return r;const u=void 0!==this._functionalKeyCodes[e.key]||void 0!==this._getNumpadKeyCode(e);if(8&t||a&&3===i||(1&t||a)&&(u&&!_||o>0&&1!==e.key.length||o-1>1))r.key=this._buildCsiUSequence(e,d,o,i,t,u,n),r.cancel=!0;else{const t=13===d?"\r":9===d?"\t":127===d?"":void 0;t?r.key=t:1!==e.key.length||e.ctrlKey||e.altKey||e.metaKey||(r.key=e.key)}return r}static shouldUseProtocol(e){return e>0}}},726(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let o=r;o=i)return this._interim=r,s;const n=e.charCodeAt(o);56320<=n&&n<=57343?t[s++]=1024*(r-55296)+n-56320+65536:(t[s++]=r,t[s++]=n);continue}65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,o,n,a,h=0,l=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let o,n=0;for(;(o=this.interim[++n])&&n<4;)r<<=6,r|=63&o;const a=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=a-n;for(;l=i)return 0;if(o=e[l++],128!=(192&o)){l--,s=!0;break}this.interim[n++]=o,r<<=6,r|=63&o}s||(2===a?r<128?l--:t[h++]=r:3===a?r<2048||r>=55296&&r<=57343||65279===r||(t[h++]=r):r<65536||r>1114111||(t[h++]=r)),this.interim.fill(0)}const c=i-4;let d=l;for(;d=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(a=(31&s)<<6|63&r,a<128){d--;continue}t[h++]=a}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(a=(15&s)<<12|(63&r)<<6|63&o,a<2048||a>=55296&&a<=57343||65279===a)continue;t[h++]=a}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=o,h;if(n=e[d++],128!=(192&n)){d--;continue}if(a=(7&s)<<18|(63&r)<<12|(63&o)<<6|63&n,a<65536||a>1114111)continue;t[h++]=a}}return h}}},7428(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],o=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let n;t.UnicodeV6=class{constructor(){if(this.version="6",!n){n=new Uint8Array(65536),n.fill(1),n[0]=0,n.fill(0,1,32),n.fill(0,127,160),n.fill(2,4352,4448),n[9001]=2,n[9002]=2,n.fill(2,11904,42192),n[12351]=1,n.fill(2,44032,55204),n.fill(2,63744,64256),n.fill(2,65040,65050),n.fill(2,65072,65136),n.fill(2,65280,65377),n.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},9249(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Win32InputMode=void 0,t.Win32InputMode=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){const t=this._codeToVk[e.code];return void 0!==t?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if("Enter"===e.key)return 10;if("Backspace"===e.key)return 127}const t=this._keyToControlChar[e.key];if(void 0!==t)return t;if(1===e.key.length){const t=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&("ControlRight"===e.code?t|=4:t|=8),e.altKey&&("AltRight"===e.code?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){return{type:0,cancel:!0,key:`[${this._getVirtualKeyCode(e)};${this._getScanCode(e)};${this._getUnicodeChar(e)};${t?1:0};${this._getControlKeyState(e)};1_`}}}},3562(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(3132),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._innerWriteTimer=this._register(new s.TimeoutTimer),this._onWriteParsed=this._register(new o.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._register((0,r.toDisposable)(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed)return;if(this._isSyncWriting)return;let e;this._isSyncWriting=!0;let t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);const i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>{this._store.isDisposed||(performance.now()-i>=12?this._scheduleInnerWrite(0,e):this._innerWrite(i,e))};return void s.catch(e=>(queueMicrotask(()=>{throw e}),Promise.resolve(!1))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(t.startsWith("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(t.startsWith("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,o]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(o,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},2607(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ApcHandler=t.ApcParser=void 0;const s=i(726),r=i(4220),o=[];t.ApcParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._ident=0}start(e){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._ident,"START")}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}end(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"END",e);this._active=o,this._ident=0}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.ApcHandler=n,n._payloadLimit=1e7},9823(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),o=i(4220),n=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=n,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=n,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=n,this._ident=0}};const a=new r.Params;a.addParam(0);class h{constructor(e){this._handler=e,this._data=new o.LimitedStringBuilder(h._payloadLimit),this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString(),this._params),t instanceof Promise))return t.then(e=>(this._params=a,this._data.reset(),this._hitLimit=!1,e));return this._params=a,this._data.reset(),this._hitLimit=!1,t}}t.DcsHandler=h,h._payloadLimit=1e7},6717(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(4812),r=i(7262),o=i(1346),n=i(9823),a=i(2607);class h{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;rt),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const o=i(0,17);e.setDefault(1,0),e.addMany(s,0,2,0);for(const t of o)e.addMany([24,26,153,154],t,3,0),e.addMany(i(128,144),t,3,0),e.addMany(i(144,152),t,3,0),e.add(156,t,0,0),e.add(27,t,11,1),e.add(157,t,4,8),e.addMany([152,158],t,0,7),e.add(159,t,11,14),e.add(155,t,11,3),e.add(144,t,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,11,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany(i(32,48),14,9,15),e.addMany(i(48,127),14,15,16),e.addMany(i(48,127),15,15,16),e.addMany(r,15,0,15),e.addMany(i(32,48),15,9,15),e.add(127,15,0,15),e.addMany(s,16,16,16),e.addMany(r,16,0,16),e.addMany(i(8,14),16,16,16),e.add(127,16,0,16),e.addMany([27,156,24,26],16,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e.add(l,16,16,16),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new o.OscParser),this._dcsParser=this._register(new n.DcsParser),this._apcParser=this._register(new a.ApcParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i<60||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);this._escHandlers[i]??=[];const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){const i=e.charCodeAt(0);this._executeHandlers[i]=t,i<24&&(this._executeHandlersArr[i]=t)}clearExecuteHandler(e){const t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);this._csiHandlers[i]??=[];const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r,o,n=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let r=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](this._params),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 4:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=255&this._parseStack.transition}for(let i=n;i=60&&n<=63&&(this._collect=n,s++);let a=!1;for(;s=48&&n<=57)this._params.addDigit(n-48);else if(59===n)this._params.addParam(0);else{if(58!==n){if(n>=64&&n<=126){const e=this._csiHandlers[this._collect<<8|n];let t=e?e.length-1:-1;for(;t>=0&&(o=e[t](this._params),!0!==o);t--)if(o instanceof Promise)return r=1792,this._preserveStack(3,e,t,r,s),o;t<0&&this._csiHandlerFb(this._collect<<8|n,this._params),this.precedingJoinState=0,i=s,this.currentState=0,a=!0;break}break}this._params.addSubParam(-1)}a||(i=s-1,this.currentState=4);continue}switch(r=this._transitions.table[this.currentState<<8|(s>8){case 2:let n=i;const a=t-4;for(;n=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l););if(n>=a)for(;n=32&&(e[n]<=126||e[n]>=l);)n++;this._printHandler(e,i,n),i=n-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:i,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:const h=this._csiHandlers[this._collect<<8|s];let c=h?h.length-1:-1;for(;c>=0&&(o=h[c](this._params),!0!==o);c--)if(o instanceof Promise)return this._preserveStack(3,h,c,r,i),o;c<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do{switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}}while(++i47&&s<60);i--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:const d=this._escHandlers[this._collect<<8|s];let _=d?d.length-1:-1;for(;_>=0&&(o=d[_](),!0!==o);_--)if(o instanceof Promise)return this._preserveStack(4,d,_,r,i),o;_<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let r=i+1;;++r)if(r>=t||24===(s=e[r])||26===s||27===s||s>127&&s=t||(s=e[r])<32||s>127&&s=32&&e[s]<127||e[s]>=8&&e[s]<14||e[s]>=l))){this._apcParser.put(e,i,s),i=s-1;break}break;case 17:if(o=this._apcParser.end(24!==s&&26!==s),o)return this._preserveStack(7,[],0,r,i),o;27===s&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0}this.currentState=255&r}}}t.EscapeSequenceParser=c},1346(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(726),r=i(4220),o=[];t.OscParser=class{constructor(){this._state=0,this._active=o,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,s.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=o,this._id=-1,this._state=0}}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.OscHandler=n,n._payloadLimit=1e7},7262(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;class i{static fromArray(e){const t=new i;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new i(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>2147483647?2147483647:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=e>2147483647?2147483647:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const i=this._digitIsSub?this._subParams:this.params,s=i[t-1];i[t-1]=~s?Math.min(10*s+e,2147483647):e}}t.Params=i},3027(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new o.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=n},6097(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,e=>t(e.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(e,i)=>t(e,i.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}}},4335(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=void 0;const o=i(4812),n=i(4097),a=i(6501),h=i(8636);let l=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e,t){super(),this.isUserScrolling=!1,this._onResize=this._register(new h.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new h.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new n.BufferSet(e,this,t)),this._register(this.buffers.onBufferActivate(e=>{this._onScroll.fire(e.activeBuffer.ydisp)}))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,o=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;o===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(o+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=o-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(o,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=l,t.BufferService=l=s([r(0,a.IOptionsService),r(1,a.ILogService)],l)},5746(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},4071(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const o=i(4812),n=i(6501),a=i(8636),h=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0});let c=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new a.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new a.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new a.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new a.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}reset(){this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onBinary.fire(e))}};t.CoreService=c,t.CoreService=c=s([r(0,n.IBufferService),r(1,n.ILogService),r(2,n.IOptionsService)],c)},4720(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationLineCache=t.DecorationService=void 0;const o=i(3132),n=i(4103),a=i(4812),h=i(6501),l=i(3087),c=i(8636);let d=0,_=0,u=class extends a.Disposable{get decorations(){return this._decorations.values()}constructor(e,t){super(),this._logService=e,this._bufferService=t,this._lineCache=this._register(new f),this._onDecorationRegistered=this._register(new c.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new c.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._decorations=new l.SortedList(e=>e?.marker.line,this._logService),this._register((0,a.toDisposable)(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}registerDecoration(e){if(e.marker.isDisposed)return;const t=new p(e);if(t){const e=t.marker.onDispose(()=>t.dispose()),i=t.onDispose(()=>{i.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),e.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,i){const s=this._lineCache.getDecorationsOnLine(t);if(s)for(const t of s)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&(yield t)}forEachDecorationAtCell(e,t,i,s){const r=this._lineCache.getDecorationsOnLine(t);if(r)for(const t of r)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&s(t)}};t.DecorationService=u,t.DecorationService=u=s([r(0,h.ILogService),r(1,h.IBufferService)],u);class f extends a.Disposable{constructor(){super(...arguments),this._decorationsByLine=new Map,this._decorations=new Set,this._bufferLineListeners=this._register(new a.MutableDisposable),this._lineIndexSyncTimer=this._register(new o.MicrotaskTimer),this._lineIndexSyncCallbacks=[]}clear(){this._lineIndexSyncCallbacks.length=0,this._lineIndexSyncTimer.cancel(),this._decorationsByLine.clear(),this._decorations.clear()}add(e){this._decorations.add(e),this._addToLineBuckets(e)}remove(e){this._decorations.delete(e),this._removeFromLineBuckets(e)}getDecorationsOnLine(e){return this._decorationsByLine.get(e)}attachToBufferLines(e){const t=new a.DisposableStore;this._bufferLineListeners.value=t,t.add(e.onTrim(e=>this._handleBufferLinesTrim(e))),t.add(e.onInsert(e=>this._handleBufferLinesInsert(e))),t.add(e.onDelete(e=>this._handleBufferLinesDelete(e)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){const t=e.marker.line;if(t<0)return;e._indexedStartLine=t;const i=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{const e=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(const t of e)t()})}_handleBufferLinesTrim(e){if(e<=0)return;const t=new Map;for(const[i,s]of this._decorationsByLine){const r=i-e;r<0||this._mergeLineBucket(t,r,s)}this._decorationsByLine.clear();for(const[e,i]of t)this._decorationsByLine.set(e,i);for(const t of this._decorations)t.marker.isDisposed||(t._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,i){const s=e.get(t);if(s)for(let e=0,t=i.length;et&&(s.push(e),this._removeFromLineBuckets(e))}const r=new Map;for(const[e,s]of this._decorationsByLine){const o=e>=t?e+i:e;this._mergeLineBucket(r,o,s)}this._decorationsByLine.clear();for(const[e,t]of r)this._decorationsByLine.set(e,t);for(const e of this._decorations)e.marker.isDisposed||e._indexedStartLine>=t&&(e._indexedStartLine=e.marker.line);for(const e of s)this._addToLineBuckets(e)}_applyBufferLinesDelete(e){const t=e.index+e.amount,i=new Map;for(const[s,r]of this._decorationsByLine){if(s>=e.index&&s=t?s-e.amount:s;this._mergeLineBucket(i,o,r)}this._decorationsByLine.clear();for(const[e,t]of i)this._decorationsByLine.set(e,t);const s=[];for(const i of this._decorations){if(i.marker.isDisposed)continue;const r=i._indexedStartLine,o=this._getDecorationHeight(i);r>=t?i._indexedStartLine=i.marker.line:rt&&s.push(i)}for(const e of s)this._reindexDecoration(e)}}t.DecorationLineCache=f;class p extends a.DisposableStore{get backgroundColorRGB(){return null===this._cachedBg&&(this.options.backgroundColor?this._cachedBg=n.css.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return null===this._cachedFg&&(this.options.foregroundColor?this._cachedFg=n.css.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}constructor(e){super(),this.options=e,this.onRenderEmitter=this.add(new c.Emitter),this.onRender=this.onRenderEmitter.event,this._onDispose=this.add(new c.Emitter),this.onDispose=this._onDispose.event,this._cachedBg=null,this._cachedFg=null,this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}dispose(){this._onDispose.fire(),super.dispose()}}},6025(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class o{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=o,t.InstantiationService=class{constructor(){this._services=new o,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort((e,t)=>e.index-t.index),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const o=i.length>0?i[0].index:t.length;if(t.length!==o)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${o+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0;const o=i(4812),n=i(6501),a={trace:n.LogLevelEnum.TRACE,debug:n.LogLevelEnum.DEBUG,info:n.LogLevelEnum.INFO,warn:n.LogLevelEnum.WARN,error:n.LogLevelEnum.ERROR,off:n.LogLevelEnum.OFF};let h=class extends o.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=n.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function n(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const a=String.fromCharCode,h={DEFAULT:e=>{const t=[n(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${a(t[0])}${a(t[1])}${a(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.x};${e.y}${t}`}};class l extends s.Disposable{constructor(){super(),this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=this._register(new r.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(o))this.addProtocol(e,o[e]);for(const e of Object.keys(h))this.addEncoding(e,h[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return!this._customWheelEventHandler||!1!==this._customWheelEventHandler(e)}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return"DEFAULT"===this._activeEncoding}get isPixelEncoding(){return"SGR_PIXELS"===this._activeEncoding}}t.MouseStateService=l},56(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(4812),r=i(701),o=i(8636);t.DEFAULT_OPTIONS={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}};const n=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new o.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{-1!==e.indexOf(i)&&t()})}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=n.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"blinkIntervalDuration":if((i=Math.floor(i))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const o=i(6501);let n=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose(()=>this._removeMarkerFromLink(s,i)),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const o=t.addMarker(t.ybase+t.y),n={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(n,o)),this._entriesWithId.set(n.key,n),this._dataByLinkId.set(n.id,n),n.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every(e=>e.line!==t)){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose(()=>this._removeMarkerFromLink(i,e))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=n,t.OscLinkService=n=s([r(0,o.IBufferService)],n)},6201(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e.di$dependencies||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const i=function(e,t,s){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,i){t.di$target===t?t.di$dependencies.push({id:e,index:i}):(t.di$dependencies=[{id:e,index:i}],t.di$target=t)}(i,e,s)};return i._id=e,t.serviceRegistry.set(e,i),i},t.serviceRegistry=new Map},6501(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.IMouseStateService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.IMouseStateService=(0,s.createDecorator)("MouseStateService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8636);class r{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.Emitter,this.onChange=this._onChange.event}static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e,this._active||(this.activeVersion=e.version)}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let o=0;o=s)return t+this.wcwidth(n);const i=e.charCodeAt(o);56320<=i&&i<=57343?n=1024*(n-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(n,i);let h=r.extractWidth(a);r.extractShouldJoin(a)&&(h-=r.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=r}},t={};return function i(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s].call(o.exports,o,o.exports,i),o.exports}(6081)})()); ++!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,()=>(()=>{"use strict";var e={2840(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new c.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(e=>this._handleResize(e.rows))),this._register(this._terminal.onRender(e=>this._refreshRows(e.start,e.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(e=>this._handleChar(e))),this._register(this._terminal.onLineFeed(()=>this._handleChar("\n"))),this._register(this._terminal.onA11yTab(e=>this._handleTab(e))),this._register(this._terminal.onKey(e=>this._handleKey(e.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register((0,f.addDisposableListener)(r,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,d.toDisposable)(()=>{this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent=l.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],o=e?.translateToString(!0,void 0,void 0,t)||"",n=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===o.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=o,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",n),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent===l.tooMuchOutput.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,o;if(0===t?(r=i,o=this._rowElements.pop(),this._rowContainer.removeChild(o)):(r=this._rowElements.shift(),o=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),o.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(0===this._rowElements.length)return;const e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed)return void(this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection());if(!e.anchorNode||!e.focusNode)return void console.error("anchorNode and/or focusNode are null");let t={node:e.anchorNode,offset:e.anchorOffset},i={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(i.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===i.node&&t.offset>i.offset)&&([t,i]=[i,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;const s=this._rowElements.slice(-1)[0];if(i.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(i={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(i.node))return;const r=({node:e,offset:t})=>{const i=e instanceof Text?e.parentNode:e;let s=parseInt(i?.getAttribute("aria-posinset"),10)-1;if(isNaN(s))return console.warn("row is invalid. Race condition?"),null;const r=this._rowColumns.get(i);if(!r)return console.warn("columns is null. Race condition?"),null;let o=t=this._terminal.cols&&(++s,o=0),{row:s,column:o}},o=r(t),n=r(i);if(o&&n){if(o.row>n.row||o.row===n.row&&o.column>=n.column)throw new Error("invalid range");this._terminal.select(o.column,o.row,(n.row-o.row)*this._terminal.cols-o.column+n.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;ethis._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(e=>this._reportWindowsOptions(e))),this._register(this._inputHandler.onColor(e=>this._handleColorEvent(e))),this._register(I.EventUtils.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(I.EventUtils.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(I.EventUtils.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(I.EventUtils.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(e=>this._afterResize(e.cols,e.rows))),this._register((0,N.toDisposable)(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i;switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=E.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`]${i};${(0,M.toRgbString)(s)}\\`);break;case 1:if("ansi"===e)this._themeService.modifyColors(e=>e.ansi[t.index]=E.channels.toColor(...t.color));else{const i=e;this._themeService.modifyColors(e=>e[i]=E.channels.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index)}}}_reportColorScheme(){if(!this._themeService)return;const e=E.rgb.relativeLuminance(this._themeService.colors.background.rgba>>8)>8)?1:2;this.coreService.triggerDataEvent(`[?997;${e}n`)}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(""),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),o=this._renderService.dimensions.css.cell.width*r,n=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=n+"px",this.textarea.style.width=o+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this._register((0,H.addDisposableListener)(this.element,"copy",e=>{this.hasSelection()&&(0,a.copyHandler)(e,this._selectionService)}));const e=e=>(0,a.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this._register((0,H.addDisposableListener)(this.textarea,"paste",e)),this._register((0,H.addDisposableListener)(this.element,"paste",e)),x.isFirefox?this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>{2===e.button&&(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register((0,H.addDisposableListener)(this.element,"contextmenu",e=>{(0,a.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),x.isLinux&&this._register((0,H.addDisposableListener)(this.element,"auxclick",e=>{1===e.button&&(0,a.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)}))}_bindKeys(){this._register((0,H.addDisposableListener)(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register((0,H.addDisposableListener)(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register((0,H.addDisposableListener)(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register((0,H.addDisposableListener)(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register((0,H.addDisposableListener)(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService)return void(this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView));this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",e=>this.element.classList.toggle("allow-transparency",e))),e.appendChild(this.element);const t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register((0,H.addDisposableListener)(this.screenElement,"mousemove",e=>this.updateCursorStyle(e))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);const i=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",h.promptLabel.get()),x.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>i.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(g.CoreBrowserService,this.textarea,e.ownerDocument.defaultView??window,this._document??("undefined"!=typeof window?window.document:null))),this._instantiationService.setService(C.ICoreBrowserService,this._coreBrowserService),this._register((0,H.addDisposableListener)(this.textarea,"focus",e=>this._handleTextAreaFocus(e))),this._register((0,H.addDisposableListener)(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(p.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(C.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(k.ThemeService),this._instantiationService.setService(C.IThemeService,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(v.CharacterJoinerService),this._instantiationService.setService(C.ICharacterJoinerService,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(w.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(C.IRenderService,this._renderService),this._register(this._renderService.onRenderedViewportChange(e=>this._onRender.fire(e))),this._register(this._renderService.onDimensionsChange(e=>this._onDimensionsChange.fire({css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}))),this.onResize(e=>this._renderService.resize(e.cols,e.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(u.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(S.MouseCoordsService),this._instantiationService.setService(C.IMouseCoordsService,this._mouseCoordsService);const s=this._linkifier.value=this._register(this._instantiationService.createInstance(O.Linkifier,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(e){this._logService.error("onWillOpen handler threw an exception",e)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(c.Viewport,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(y.SelectionService,this.element,this.screenElement,s)),this._instantiationService.setService(C.ISelectionService,this._selectionService),this._mouseService=this._instantiationService.createInstance(b.MouseService),this._instantiationService.setService(C.IMouseService,this._mouseService),this._register(this._selectionService.onRequestScrollLines(e=>this.scrollLines(e.amount,e.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()})),this._register(I.EventUtils.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(d.BufferDecorationRenderer,this.screenElement)),this._register((0,H.addDisposableListener)(this.element,"mousedown",e=>this._selectionService.handleMouseDown(e))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(P.AccessibilityManager,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",e=>this._handleScreenReaderModeOptionChange(e)));const r=this.options.scrollbar?.showScrollbar??!0,o=this.options.scrollbar?.width;r&&o&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",e=>{const t=(e?.showScrollbar??!0)&&!!e?.width;!this._overviewRulerRenderer&&t&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(_.OverviewRulerRenderer,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:e=>this._viewport?.handleTouchScroll(e)},e=>this._register(e),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(f.DomRenderer,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,i=!1){this._renderService?.refreshRows(e,t,i)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}paste(e){(0,a.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),e.preventDefault(),e.stopPropagation(),!1}if(1===i.type&&this.selectAll(),this._isThirdLevelShift(this.browser,e))return!0;if(i.cancel&&(e.preventDefault(),e.stopPropagation()),!i.key)return!0;if(!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;""!==i.key&&"\r"!==i.key||(this.textarea.value="");const s=this._keyboardService.useWin32InputMode&&W(e);if(this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return;W(e)||this.focus();const t=this._keyboardService.evaluateKeyUp(e);if(t?.key){const i=this._keyboardService.useWin32InputMode&&W(e);this.coreService.triggerDataEvent(t.key,!i)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this._compositionHelper.keypress(t)||this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;efunction(e){const t=l(e);for(t.animFrameRequested=!1,t.current=t.next,t.next=[],t.inAnimationFrameRunner=!0;t.current.length>0;)t.current.sort(a.sort),t.current.shift().execute();t.inAnimationFrameRunner=!1}(e))),r};const s=i(3132);function r(e){const t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView;const i=e;return i?.view?i.view:window}class o{constructor(e,t,i,s){this._node=e,this._type=t,this._handler=i,this._options=s,e.addEventListener(t,i,s)}dispose(){this._node&&this._handler&&(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}}function n(e,t,i,s){return new o(e,t,i,s)}t.eventType={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};class a{constructor(e,t){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){console.error(e)}}static sort(e,t){return t.priority-e.priority}}const h=new Map;function l(e){let t=h.get(e);return t||(t={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},h.set(e,t)),t}class c extends s.IntervalTimer{constructor(e){super(),this._defaultTarget=e?r(e):void 0}cancelAndSet(e,t,i){super.cancelAndSet(e,t,i??this._defaultTarget??window)}}t.WindowIntervalTimer=c},8906(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier=void 0;const o=i(4812),n=i(6501),a=i(7098),h=i(8636),l=i(4159);let c=class extends o.Disposable{get currentLink(){return this._currentLink}constructor(e,t,i,s,r){super(),this._element=e,this._mouseCoordsService=t,this._renderService=i,this._bufferService=s,this._linkProviderService=r,this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this._register(new h.Emitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this._register(new h.Emitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this._register((0,o.toDisposable)(()=>{(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register((0,l.addDisposableListener)(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register((0,l.addDisposableListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register((0,l.addDisposableListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){this._lastMouseEvent=e;const t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{e?.forEach(e=>{e.link.dispose&&e.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let i=!1;for(const[s,r]of this._linkProviderService.linkProviders.entries())if(t){const t=this._activeProviderReplies?.get(s);t&&(i=this._checkLinkProviderResult(s,e,i))}else r.provideLinks(e.y,t=>{if(this._isMouseOut)return;const r=t?.map(e=>({link:e}));this._activeProviderReplies?.set(s,r),i=this._checkLinkProviderResult(s,e,i),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=o;e<=n;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){if(!this._activeProviderReplies)return i;const s=this._activeProviderReplies.get(e);let r=!1;for(let t=0;tthis._linkAtPosition(e.link,t));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t));if(s){i=!0,this._handleNewLink(s);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element);var i,s;t&&this._mouseDownLink&&(i=this._mouseDownLink.link,s=this._currentLink.link,i.text===s.text&&i.range.start.x===s.range.start.x&&i.range.start.y===s.range.start.y&&i.range.end.x===s.range.end.x&&i.range.end.y===s.range.end.y)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,o.dispose)(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element);e&&this._askForLink(e,!1)}})))}_linkHover(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t){const i=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(i)return{x:i[0],y:i[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier=c,t.Linkifier=c=s([r(1,a.IMouseCoordsService),r(2,a.IRenderService),r(3,n.IBufferService),r(4,a.ILinkProviderService)],c)},7721(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0;let i="Terminal input";const s={get:()=>i,set:e=>i=e};t.promptLabel=s;let r="Too much output to announce, navigate to rows manually to read";const o={get:()=>r,set:e=>r=e};t.tooMuchOutput=o},3285(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const o=i(3055),n=i(6501);let a=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i,this._workCell=new o.CellData}provideLinks(e,t){const i=this._bufferService.buffer.lines.get(e-1);if(!i)return void t(void 0);const s=[],r=this._optionsService.rawOptions.linkHandler,o=this._workCell,n=i.getTrimmedLength();let a=-1,l=-1,c=!1;for(let t=0;tr?r.activate(e,t,d):h(0,t),hover:(e,t)=>r?.hover?.(e,t,d),leave:(e,t)=>r?.leave?.(e,t,d)})}c=!1,o.hasExtendedAttrs()&&o.extended.urlId?(l=t,a=o.extended.urlId):(l=-1,a=-1)}}t(s)}_getRangeWithLineWrap(e,t,i,s){let r=e,o=t,n=e,a=i;for(;0===o;){const e=this._bufferService.buffer.lines.get(r-1);if(!e?.isWrapped)break;const t=this._bufferService.buffer.lines.get(r-2);if(!t)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,i-1,s))break;let n=i-1;for(;n>0&&this._hasUrlId(t,n-1,s);)n--;r--,o=n}for(;;){const e=this._bufferService.buffer.lines.get(n-1);if(!e)break;if(a!==e.getTrimmedLength())break;const t=this._bufferService.buffer.lines.get(n);if(!t?.isWrapped)break;const i=t.getTrimmedLength();if(0===i||!this._hasUrlId(t,0,s))break;let r=1;for(;rthis._innerRefresh()),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},4292(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(e,t,i){this._rowCount=i,e=e??0,t=t??this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=performance.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)void 0!==this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},9302(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_ANSI_COLORS=void 0;const s=i(4103);t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const r=t[i/36%6|0],o=t[i/6%6|0],n=t[i%6];e.push({css:s.channels.toCss(r,o,n),rgba:s.channels.toRgba(r,o,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:s.channels.toCss(i,i,i),rgba:s.channels.toRgba(i,i,i)})}return e})())},4017(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const o=i(7098),n=i(4812),a=i(6501),h=i(4159),l=i(8566),c=i(8636),d=i(7880);let _=class extends n.Disposable{constructor(e,t,i,s,r,o,a,_,u){super(),this._bufferService=i,this._coreService=r,this._optionsService=_,this._renderService=u,this._onRequestScrollLines=this._register(new c.Emitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1,this._needsSyncOnRender=!1;const f=this._register(new d.Scrollable({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>(0,h.scheduleAtNextAnimationFrame)(s.window,e)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{f.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new l.SmoothScrollableElement(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},f)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(o.onProtocolChange(e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(16&e)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{e.style.backgroundColor=a.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=a.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register((0,n.toDisposable)(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register((0,n.toDisposable)(()=>this._styleElement.remove())),this._register(c.EventUtils.runAndSubscribe(a.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${a.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${a.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${a.colors.scrollbarSliderActiveBackground.css};`,"}"].join("\n")})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(e=>this._handleScroll(e)))}scrollLines(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){const e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,i=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:i,verticalHasArrows:t}}queueSync(e){void 0!==e&&(this._latestYDisp=e),void 0===this._queuedAnimationFrame&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){this._renderService&&!this._isSyncing&&(this._coreService.decPrivateModes.synchronizedOutput?this._needsSyncOnRender=!0:(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1))}_handleScroll(e){if(!this._renderService)return;if(this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;const t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),i=t-this._bufferService.buffer.ydisp;0!==i&&(this._latestYDisp=t,this._onRequestScrollLines.fire(i)),this._isHandlingScroll=!1}handleTouchScroll(e){const t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};t.Viewport=_,t.Viewport=_=s([r(2,a.IBufferService),r(3,o.ICoreBrowserService),r(4,a.ICoreService),r(5,a.IMouseStateService),r(6,o.IThemeService),r(7,a.IOptionsService),r(8,o.IRenderService)],_)},4196(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const o=i(7098),n=i(4812),a=i(6501);let h=class extends n.Disposable{constructor(e,t,i,s,r){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=i,this._decorationService=s,this._renderService=r,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(e=>this._removeDecoration(e))),this._register((0,n.toDisposable)(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){const t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer","top"===e?.options?.layer),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",t.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const i=e.options.x??0;return i&&i>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose(()=>{this._decorationElements.delete(e),i.remove()})),i.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(i.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,i.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){if(!t)return;const i=e.options.x??0;"right"===(e.options.anchor||"left")?t.style.right=i?i*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=i?i*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=h,t.BufferDecorationRenderer=h=s([r(1,a.IBufferService),r(2,o.ICoreBrowserService),r(3,a.IDecorationService),r(4,o.IRenderService)],h)},957(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},9925(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const o=i(957),n=i(7098),a=i(4812),h=i(6501),l={full:0,left:0,center:0,right:0},c={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0};let _=class extends a.Disposable{get _width(){const e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??1?e?.width??0:0}constructor(e,t,i,s,r,n,h,l){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=n,this._themeService=h,this._coreBrowserService=l,this._colorZoneStore=new o.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register((0,a.toDisposable)(()=>this._canvas?.remove()));const c=this._canvas.getContext("2d");if(!c)throw new Error("Ctx cannot be null");this._ctx=c,this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register((0,a.toDisposable)(()=>{void 0!==this._animationFrame&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);c.full=this._canvas.width,c.left=e,c.center=t,c.right=e,this._refreshDrawHeightConstants(),d.full=1,d.left=1,d.center=1+c.left,d.right=1+c.left+c.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowserService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;const e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(d[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),c[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=_,t.OverviewRulerRenderer=_=s([r(2,h.IBufferService),r(3,h.IDecorationService),r(4,n.IRenderService),r(5,h.IOptionsService),r(6,n.IThemeService),r(7,n.ICoreBrowserService)],_)},3618(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const o=i(7098),n=i(6501);let a=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,o){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=o,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent="",this._pendingKeypressData=""}compositionstart(){this._isComposing=!0;const e=this._textarea.selectionStart??this._textarea.value.length,t=this._textarea.selectionEnd??e;this._compositionPosition.start=Math.min(e,t),this._compositionPosition.end=Math.max(e,t),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=`‎${e.data}‎`,this.updateCompositionElements(),setTimeout(()=>{const e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(20===e.keyCode||229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}keypress(e){return!!this._isSendingComposition&&(this._pendingKeypressData+=e,!0)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._pendingKeypressData="",this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){let i;if(this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const s=this._textarea.value,r=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;i=s.substring(e.start,Math.max(e.start,r))}this._sendCompositionInput(i)}},0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._sendCompositionInput(e)}}_sendCompositionInput(e){const t=this._pendingKeypressData;if(!e.includes(t))if(t.includes(e))e=t;else{let i=Math.min(e.length,t.length);for(;i>0&&!e.endsWith(t.substring(0,i));)i--;let s=Math.min(e.length,t.length);for(;s>0&&!t.endsWith(e.substring(0,s));)s--;e=i>s?e+t.substring(i):t+e.substring(s)}this._pendingKeypressData="",e.length>0&&this._coreService.triggerDataEvent(e,!0)}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;const e=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0),0)}}};t.CompositionHelper=a,t.CompositionHelper=a=s([r(2,n.IBufferService),r(3,n.IOptionsService),r(4,n.ICoreService),r(5,o.IRenderService)],a)},5251(e,t){function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),o=parseInt(r.getPropertyValue("padding-left"),10),n=parseInt(r.getPropertyValue("padding-top"),10);return[t.clientX-s.left-o,t.clientY-s.top-n]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,o,n,a,h,l){if(!n)return;const c=i(e,t,s);return c[0]=Math.ceil((c[0]+(l?a/2:0))/a),c[1]=Math.ceil(c[1]/h),c[0]=Math.min(Math.max(c[0],1),r+(l?1:0)),c[1]=Math.min(Math.max(c[1],1),o),c}},9686(e,t){function i(e,t,i,o){const h=e-s(e,i),l=t-s(t,i),c=Math.abs(h-l)-function(e,t,i){let o=0;const n=e-s(e,i),a=t-s(t,i);for(let s=0;s=0&&et?"A":"B"}function o(e,t,i,s,r,o){let n=e,a=t,h="";for(;(n!==i||a!==s)&&a>=0&&ao.cols-1?(h+=o.buffer.translateBufferLineToString(a,!1,e,n),n=0,e=0,a++):!r&&n<0&&(h+=o.buffer.translateBufferLineToString(a,!1,0,e+1),n=o.cols-1,e=n,a--);return h+o.buffer.translateBufferLineToString(a,!1,e,n)}function n(e,t){return""+(t?"O":"[")+e}function a(e,t){e=Math.floor(e);let i="";for(let s=0;s0?h-s(h,l):t;const _=h,u=function(e,t,r,o,n,a){let h;return h=i(t,o,n,a).length>0?o-s(o,n):t,e=r&&he?"D":"C",a(Math.abs(l-e),n(d,h));d=c>t?"D":"C";const _=Math.abs(c-t);return a(function(e,t){return t.cols-e}(c>t?e:l,r)+(_-1)*r.cols+1+((c>t?l:e)-1),n(d,h))}},6081(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(f.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new _.ParserApi(this._core)}get unicode(){return this._checkProposedApi(),new u.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new d.BufferNamespaceApi(this._core))}get markers(){return this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.mouseStateService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:e.synchronizedOutput,win32InputMode:e.win32InputMode,wraparoundMode:e.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return a.promptLabel.get()},set promptLabel(e){a.promptLabel.set(e)},get tooMuchOutput(){return a.tooMuchOutput.get()},set tooMuchOutput(e){a.tooMuchOutput.set(e)}}}_verifyIntegers(...e){for(p of e)if(p===1/0||isNaN(p)||p%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(p of e)if(p&&(p===1/0||isNaN(p)||p%1!=0||p<0))throw new Error("This API only accepts positive integers")}}t.Terminal=v},3955(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const o=i(1433),n=i(2744),a=i(9176),h=i(6181),l=i(2274),c=i(654),d=i(7098),_=i(4103),u=i(4812),f=i(6501),p=i(8636),v=i(4159);let g=1,m=class extends u.Disposable{constructor(e,t,i,s,r,a,d,_,f,m,b,w,y,C){super(),this._terminal=e,this._document=t,this._element=i,this._screenElement=s,this._viewportElement=r,this._helperContainer=a,this._linkifier2=d,this._charSizeService=f,this._optionsService=m,this._bufferService=b,this._coreService=w,this._coreBrowserService=y,this._themeService=C,this._terminalClass=g++,this._rowElements=[],this._selectionRenderModel=(0,l.createSelectionRenderModel)(),this._lastSelectionColumnMode=!1,this._rowHasBlinkingCells=[],this._rowHasBlinkingCellsCount=0,this._onRequestRedraw=this._register(new p.Emitter),this.onRequestRedraw=this._onRequestRedraw.event,this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(e=>this._injectCss(e))),this._injectCss(this._themeService.colors),this._rowFactory=_.createInstance(o.DomRendererRowFactory,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(e=>this._handleLinkHover(e))),this._register(this._linkifier2.onHideLinkUnderline(e=>this._handleLinkLeave(e))),this._cursorBlinkStateManager=new S(this._rowContainer,this._coreBrowserService),this._register((0,v.addDisposableListener)(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register((0,u.toDisposable)(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new c.TextBlinkStateManager(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register((0,u.toDisposable)(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new n.WidthCache,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${_.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;const i=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,r=`blink_block_${this._terminalClass}`;t+=`@keyframes ${i} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${r} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${i} { color: ${s.css}; }${this._terminalSelector} .xterm-fg-${i}.xterm-dim { color: ${_.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .xterm-bg-${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR} { color: ${_.color.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${_.color.multiplyOpacity(_.color.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=this._document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,i){const s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i);let r=0,o=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(r=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow));let n=0,a=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,i),this._selectionRenderModel.hasSelection){const s=this._selectionRenderModel.viewportStartRow,r=this._selectionRenderModel.viewportEndRow,o=this._selectionRenderModel.viewportCappedStartRow,h=this._selectionRenderModel.viewportCappedEndRow;n=o,a=h;const l=this._document.createDocumentFragment();if(i){const i=e[0]>t[0];l.appendChild(this._createSelectionElement(o,i?t[0]:e[0],i?e[0]:t[0],h-o+1))}else{const i=s===o?e[0]:0,n=o===r?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(o,i,n));const a=h-o-1;if(l.appendChild(this._createSelectionElement(o+1,0,this._bufferService.cols,a)),o!==h){const e=r===h?t[0]:this._bufferService.cols;l.appendChild(this._createSelectionElement(h,0,e))}}this._selectionContainer.appendChild(l)}let h=Math.min(r,n),l=Math.max(o,a);if(l>=0){h=Math.max(h,0),l=Math.min(l,s-1);const e=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&e>=0&&ethis.dimensions.css.canvas.width&&(n=this.dimensions.css.canvas.width-o),r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=`${o}px`,r.style.width=`${n}px`,r}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),o=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,n=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle,h={hasBlinkingCells:!1};for(let l=e;l<=t;l++){const e=l+i.ydisp,t=this._rowElements[l];if(!t)continue;const c=i.lines.get(e);c?(t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,n,a,r,o,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,h)),this._setRowBlinkState(l,h.hasBlinkingCells)):(t.replaceChildren(),this._setRowBlinkState(l,!1))}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,o){i<0&&(e=0),s<0&&(t=0);const n=this._bufferService.rows-1;i=Math.max(Math.min(i,n),0),s=Math.max(Math.min(s,n),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,l=Math.min(a.x,r-1),c=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle,u={hasBlinkingCells:!1};for(let n=i;n<=s;++n){const f=n+a.ydisp,p=this._rowElements[n];if(!p)continue;const v=a.lines.get(f);v?(p.replaceChildren(...this._rowFactory.createRow(v,f,f===h,d,_,l,c,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,o?n===i?e:0:-1,o?(n===s?t:r)-1:-1,u)),this._setRowBlinkState(n,u.hasBlinkingCells)):(p.replaceChildren(),this._setRowBlinkState(n,!1))}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};t.DomRenderer=m,t.DomRenderer=m=s([r(7,f.IInstantiationService),r(8,d.ICharSizeService),r(9,f.IOptionsService),r(10,f.IBufferService),r(11,f.ICoreService),r(12,d.ICoreBrowserService),r(13,d.IThemeService)],m);class S{constructor(e,t){this._rowContainer=e,this._coreBrowserService=t,this._isIdlePaused=!1,this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){void 0!==this._idleTimeout&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}}},1433(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const o=i(9176),n=i(8938),a=i(3055),h=i(6501),l=i(4103),c=i(7098),d=i(945),_=i(6181),u=i(5451);let f=class{constructor(e,t,i,s,r,o,n){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=o,this._themeService=n,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,c,_,f,p,v,g){const m=[];g&&(g.hasBlinkingCells=!1);const S=this._characterJoinerService.getJoinedCharacters(t),b=this._themeService.colors;let w,y=e.getNoBgTrimmedLength();i&&y=A,F=I,W=this._workCell;if(S.length>0&&I===S[0][0]&&N){const s=S.shift(),r=this._isCellInSelection(s[0],t);for(C=s[0]+1;C=s[1],N?(H=!0,W=new d.JoinedCellData(this._workCell,e.translateToString(!0,s[0],s[1]),s[1]-s[0]),F=s[1]-1,y=W.getWidth()):A=s[1]}const z=this._isCellInSelection(I,t),K=i&&I===a,U=O&&I>=p&&I<=v;g&&W.isBlink()&&(g.hasBlinkingCells=!0),!c&&W.isBlink()&&P.push("xterm-blink-hidden");let j=!1;this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{j=!0});let $=W.getChars()||n.WHITESPACE_CELL_CHAR;if(" "===$&&(W.isUnderline()||W.isOverline())&&($=" "),k=y*_-f.get($,W.isBold(),W.isItalic()),w){if(D&&(z&&T||!z&&!T&&W.bg===L)&&(z&&T&&b.selectionForeground||W.fg===x)&&W.extended.ext===R&&U===M&&k===B&&!K&&!H&&!j&&N){W.isInvisible()?E+=n.WHITESPACE_CELL_CHAR:E+=$,D++;continue}D&&(w.textContent=E),w=this._document.createElement("span"),D=0,E=""}else w=this._document.createElement("span");if(L=W.bg,x=W.fg,R=W.extended.ext,M=U,B=k,T=z,H&&a>=I&&a<=F&&(a=I),!this._coreService.isCursorHidden&&K&&this._coreService.isCursorInitialized)if(P.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&P.push("xterm-cursor-blink"),P.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":P.push("xterm-cursor-outline");break;case"block":P.push("xterm-cursor-block");break;case"bar":P.push("xterm-cursor-bar");break;case"underline":P.push("xterm-cursor-underline")}if(W.isBold()&&P.push("xterm-bold"),W.isItalic()&&P.push("xterm-italic"),W.isDim()&&P.push("xterm-dim"),E=W.isInvisible()?n.WHITESPACE_CELL_CHAR:W.getChars()||n.WHITESPACE_CELL_CHAR,W.isUnderline()&&(P.push(`xterm-underline-${W.extended.underlineStyle}`)," "===E&&(E=" "),!W.isUnderlineColorDefault()))if(W.isUnderlineColorRGB())w.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(W.getUnderlineColor()).join(",")})`;else{let e=W.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&W.isBold()&&e<8&&(e+=8),w.style.textDecorationColor=b.ansi[e].css}W.isOverline()&&(P.push("xterm-overline")," "===E&&(E=" ")),W.isStrikethrough()&&P.push("xterm-strikethrough"),U&&(w.style.textDecoration="underline");let q=W.getFgColor(),V=W.getFgColorMode(),X=W.getBgColor(),Y=W.getBgColorMode();const G=!!W.isInverse();if(G){const e=q;q=X,X=e;const t=V;V=Y,Y=t}let J,Z,Q,ee=!1;switch(this._decorationService.forEachDecorationAtCell(I,t,void 0,e=>{"top"!==e.options.layer&&ee||(e.backgroundColorRGB&&(Y=50331648,X=e.backgroundColorRGB.rgba>>8&16777215,J=e.backgroundColorRGB),e.foregroundColorRGB&&(V=50331648,q=e.foregroundColorRGB.rgba>>8&16777215,Z=e.foregroundColorRGB),ee="top"===e.options.layer)}),!ee&&z&&(J=this._coreBrowserService.isFocused?b.selectionBackgroundOpaque:b.selectionInactiveBackgroundOpaque,X=J.rgba>>8&16777215,Y=50331648,ee=!0,b.selectionForeground&&(V=50331648,q=b.selectionForeground.rgba>>8&16777215,Z=b.selectionForeground)),ee&&P.push("xterm-decoration-top"),Y){case 16777216:case 33554432:Q=b.ansi[X],P.push(`xterm-bg-${X}`);break;case 50331648:Q=l.channels.toColor(X>>16,X>>8&255,255&X),this._addStyle(w,`background-color:#${(X>>>0).toString(16).padStart(6,"0")}`);break;default:G?(Q=b.foreground,P.push(`xterm-bg-${o.INVERTED_DEFAULT_COLOR}`)):Q=b.background}switch(J||W.isDim()&&(J=l.color.multiplyOpacity(Q,.5)),V){case 16777216:case 33554432:W.isBold()&&q<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(q+=8),this._applyMinimumContrast(w,Q,b.ansi[q],W,J,void 0)||P.push(`xterm-fg-${q}`);break;case 50331648:const e=l.channels.toColor(q>>16&255,q>>8&255,255&q);this._applyMinimumContrast(w,Q,e,W,J,Z)||this._addStyle(w,`color:#${q.toString(16).padStart(6,"0")}`);break;default:this._applyMinimumContrast(w,Q,b.foreground,W,J,Z)||G&&P.push(`xterm-fg-${o.INVERTED_DEFAULT_COLOR}`)}P.length&&(w.className=P.join(" "),P.length=0),K||H||j||!N?w.textContent=E:D++,k!==this.defaultSpacing&&(w.style.letterSpacing=`${k}px`),m.push(w),I=F}return w&&D&&(w.textContent=E),m}_applyMinimumContrast(e,t,i,s,r,o){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.treatGlyphAsBackgroundColor)(s.getCode()))return!1;const n=this._getContrastCache(s);let a;if(r||o||(a=n.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=l.color.ensureContrastRatio(r??t,o??i,e),n.setColor((r??t).rgba,(o??i).rgba,a??null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};t.DomRendererRowFactory=f,t.DomRendererRowFactory=f=s([r(1,c.ICharacterJoinerService),r(2,h.IOptionsService),r(3,c.ICoreBrowserService),r(4,h.ICoreService),r(5,h.IDecorationService),r(6,c.IThemeService)],f)},2744(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0;const s=i(6181);t.WidthCache=class{constructor(e=()=>new r){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._canvasElements=[],this._canvasElements=[e(),e(),e(),e()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._canvasElements[0].setFont(e,t,i,!1),this._canvasElements[1].setFont(e,t,s,!1),this._canvasElements[2].setFont(e,t,i,!0),this._canvasElements[3].setFont(e,t,s,!0),this.clear())}get(e,t,i){let s;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256){if(-9999!==this._flat[s])return this._flat[s];const t=this._measure(e,0);return t>0&&(this._flat[s]=t),t}let r=e;t&&(r+="B"),i&&(r+="I");let o=this._holey.get(r);if(void 0===o){let s=0;t&&(s|=1),i&&(s|=2),o=this._measure(e,s),o>0&&this._holey.set(r,o)}return o}_measure(e,t){return this._canvasElements[t].measure(e)}};class r{constructor(){"undefined"!=typeof OffscreenCanvas?(this._canvas=new OffscreenCanvas(1,1),this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=(0,s.throwIfFalsy)(this._canvas.getContext("2d")))}setFont(e,t,i,s){const r=s?"italic":"";this._ctx.font=`${r} ${i} ${t}px ${e}`.trim()}measure(e){return this._ctx.measureText(e).width}}},9176(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.INVERTED_DEFAULT_COLOR=void 0,t.INVERTED_DEFAULT_COLOR=257},6181(e,t){function i(e){return 57508<=e&&e<=57558}function s(e){return e>=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}Object.defineProperty(t,"__esModule",{value:!0}),t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.isEmoji=s,t.allowRescaling=function(e,t,r,o){return 1===t&&r>Math.ceil(1.5*o)&&void 0!==e&&e>255&&!s(e)&&!i(e)&&!function(e){return 57344<=e&&e<=63743}(e)},t.treatGlyphAsBackgroundColor=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}},t.computeNextVariantOffset=function(e,t,i=0){return(e-(2*Math.round(t)-i))%(2*Math.round(t))}},2274(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.createSelectionRenderModel=function(){return new i};class i{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,i,s=!1){if(this.selectionStart=t,this.selectionEnd=i,!t||!i||t[0]===i[0]&&t[1]===i[1])return void this.clear();const r=e.buffers.active.ydisp,o=t[1]-r,n=i[1]-r,a=Math.max(o,0),h=Math.min(n,e.rows-1);a>=e.rows||h<0?this.clear():(this.hasSelection=!0,this.columnSelectMode=s,this.viewportStartRow=o,this.viewportEndRow=n,this.viewportCappedStartRow=a,this.viewportCappedEndRow=h,this.startCol=t[0],this.endCol=i[0])}isCellSelected(e,t,i){return!!this.hasSelection&&(i-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&i>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&i<=this.viewportCappedEndRow:i>this.viewportStartRow&&i=this.startCol&&t=this.startCol)}}},654(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.TextBlinkStateManager=void 0;const s=i(4812);class r extends s.Disposable{constructor(e,t,i){super(),this._renderCallback=e,this._coreBrowserService=t,this._optionsService=i,this._intervalDuration=0,this._blinkOn=!0,this._needsBlinkInViewport=!1,this._isViewportVisible=!0,this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",e=>{this.setIntervalDuration(e)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register((0,s.toDisposable)(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(void 0!==this._interval)return;const e=this._blinkOn;return this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),void(e||this._renderCallback())}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){void 0!==this._interval&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}}t.TextBlinkStateManager=r},8501(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._domNodePointerDown(e)))}_createArrow(e){const t=this._register(new c.ScrollbarArrow(e));return this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode),t}_createSlider(e,t,i,s){this.slider=new h.FastDomNode(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(e),this.slider.setLeft(t),"number"==typeof i&&this.slider.setWidth(i),"number"==typeof s&&this.slider.setHeight(s),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(a.addDisposableListener(this.slider.domNode,a.eventType.POINTER_DOWN,e=>{0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))})),this._onclick(this.slider.domNode,e=>{e.leftButton&&e.stopPropagation()})}_handleElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._handlePointerDown(e)}delegatePointerDown(e){const t=this.domNode.domNode.getClientRects()[0].top,i=t+this._scrollbarState.getSliderPosition(),s=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),r=this._sliderPointerPosition(e);i<=r&&r<=s?0===e.button&&(e.preventDefault(),this._sliderPointerDown(e)):this._handlePointerDown(e)}_handlePointerDown(e){let t,i;if(e.target===this.domNode.domNode&&"number"==typeof e.offsetX&&"number"==typeof e.offsetY)t=e.offsetX,i=e.offsetY;else{const s=a.getDomNodePagePosition(this.domNode.domNode);t=e.pageX-s.left,i=e.pageY-s.top}const s=this._pointerDownRelativePosition(t,i);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(s):this._scrollbarState.getDesiredScrollPositionFromOffset(s)),0===e.button&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!(e.target&&e.target instanceof Element))return;const t=this._sliderPointerPosition(e),i=this._sliderOrthogonalPointerPosition(e),s=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{const r=this._sliderOrthogonalPointerPosition(e),o=Math.abs(r-i);if(u.isWindows&&o>140)return void this._setDesiredScrollPositionNow(s.getScrollPosition());const n=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(s.getDesiredScrollPositionFromDelta(n))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(e){const t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}}t.AbstractScrollbar=f},1203(e,t){function i(e){return"number"==typeof e?`${e}px`:e}Object.defineProperty(t,"__esModule",{value:!0}),t.FastDomNode=void 0,t.FastDomNode=class{constructor(e){this.domNode=e,this._width="",this._height="",this._top="",this._left="",this._bottom="",this._right="",this._className="",this._position="",this._layerHint=!1,this._contain="none"}setWidth(e){const t=i(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){const t=i(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){const t=i(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){const t=i(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){const t=i(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){const t=i(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=e?"translate3d(0px, 0px, 0px)":"")}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}}},928(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{try{e.releasePointerCapture(t)}catch{}}))}catch{o=a.getWindow(e)}this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_MOVE,e=>{e.buttons===i?(e.preventDefault(),this._pointerMoveCallback(e)):this.stopMonitoring(!0)})),this._hooks.add(a.addDisposableListener(o,a.eventType.POINTER_UP,e=>this.stopMonitoring(!0)))}}},9699(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.HorizontalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition();if(super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(t.horizontalHasArrows?t.horizontalScrollbarSize:0,2===t.horizontal?0:t.horizontalScrollbarSize,2===t.vertical?0:t.verticalScrollbarSize,s.width,s.scrollWidth,o.scrollLeft),visibility:t.horizontal,extraScrollbarClassName:"xterm-horizontal",scrollable:e,scrollByPage:t.scrollByPage}),t.horizontalHasArrows)throw new Error("horizontalHasArrows is not supported in xterm.js");this._createSlider(Math.floor((t.horizontalScrollbarSize-t.horizontalSliderSize)/2),0,void 0,t.horizontalSliderSize)}_updateSlider(e,t){this.slider.setWidth(e),this.slider.setLeft(t)}_renderDomNode(e,t){this.domNode.setWidth(e),this.domNode.setHeight(t),this.domNode.setLeft(0),this.domNode.setBottom(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollWidth)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollLeft)||this._shouldRender,this._shouldRender=this._handleElementSize(e.width)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return e}_sliderPointerPosition(e){return e.pageX}_sliderOrthogonalPointerPosition(e){return e.pageY}_updateScrollbarSize(e){this.slider.setHeight(e)}writeScrollPosition(e,t){e.scrollLeft=t}updateOptions(e){this.updateScrollbarSize(2===e.horizontal?0:e.horizontalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._visibilityController.setVisibility(e.horizontal),this._scrollByPage=e.scrollByPage}}t.HorizontalScrollbar=o},3988(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;ni&&(s=i-t),s<0&&(s=0),r<0&&(r=0),n+r>o&&(n=o-r),n<0&&(n=0),this.width=t,this.scrollWidth=i,this.scrollLeft=s,this.height=r,this.scrollHeight=o,this.scrollTop=n}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(e,t){return new o(this._forceIntegerValues,void 0!==e.width?e.width:this.width,void 0!==e.scrollWidth?e.scrollWidth:this.scrollWidth,t?this.rawScrollLeft:this.scrollLeft,void 0!==e.height?e.height:this.height,void 0!==e.scrollHeight?e.scrollHeight:this.scrollHeight,t?this.rawScrollTop:this.scrollTop)}withScrollPosition(e){return new o(this._forceIntegerValues,this.width,this.scrollWidth,void 0!==e.scrollLeft?e.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,void 0!==e.scrollTop?e.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){const i=this.width!==e.width,s=this.scrollWidth!==e.scrollWidth,r=this.scrollLeft!==e.scrollLeft,o=this.height!==e.height,n=this.scrollHeight!==e.scrollHeight,a=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:i,scrollWidthChanged:s,scrollLeftChanged:r,heightChanged:o,scrollHeightChanged:n,scrollTopChanged:a}}}t.ScrollState=o;class n extends r.Disposable{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new s.Emitter),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new o(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){const i=this._state.withScrollDimensions(e,t);this._setState(i,Boolean(this._smoothScrolling)),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){const t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(0!==this._smoothScrollDuration){if(this._smoothScrolling){e={scrollLeft:void 0===e.scrollLeft?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:void 0===e.scrollTop?this._smoothScrolling.to.scrollTop:e.scrollTop};const i=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===i.scrollLeft&&this._smoothScrolling.to.scrollTop===i.scrollTop)return;let s;s=t?new l(this._smoothScrolling.from,i,this._smoothScrolling.startTime,this._smoothScrolling.duration):l.start(this._state,i,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{const t=this._state.withScrollPosition(e);this._smoothScrolling=l.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}else this.setScrollPositionNow(e)}hasPendingScrollAnimation(){return Boolean(this._smoothScrolling)}_performSmoothScrolling(){if(!this._smoothScrolling)return;const e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);return this._setState(t,!0),this._smoothScrolling?e.isDone?(this._smoothScrolling.dispose(),void(this._smoothScrolling=null)):void(this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})):void 0}_setState(e,t){const i=this._state;i.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(i,t)))}}t.Scrollable=n;class a{constructor(e,t,i){this.scrollLeft=e,this.scrollTop=t,this.isDone=i}}function h(e,t){const i=t-e;return function(t){return e+i*(1-(s=1-t,Math.pow(s,3)));var s}}class l{constructor(e,t,i,s){this.from=e,this.to=t,this.duration=s,this.startTime=i,this.animationFrameDisposable=null,this._initAnimations()}_initAnimations(){this._scrollLeft=this._initAnimation(this.from.scrollLeft,this.to.scrollLeft,this.to.width),this._scrollTop=this._initAnimation(this.from.scrollTop,this.to.scrollTop,this.to.height)}_initAnimation(e,t,i){if(Math.abs(e-t)>2.5*i){let n,a;return e0&&Math.abs(e.deltaY)>0)return 1;let i=.5;if(this._isAlmostInt(e.deltaX)&&this._isAlmostInt(e.deltaY)||(i+=.25),t){const s=Math.abs(e.deltaX),r=Math.abs(e.deltaY),o=Math.abs(t.deltaX),n=Math.abs(t.deltaY),a=Math.max(Math.min(s,o),1),h=Math.max(Math.min(r,n),1),l=Math.max(s,o),c=Math.max(r,n);l%a===0&&c%h===0&&(i-=.5)}return Math.min(Math.max(i,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}}S.INSTANCE=new S;class b extends _.Widget{get options(){return this._options}constructor(e,t,i){let s;super(),this._onScroll=this._register(new f.Emitter),this.onScroll=this._onScroll.event,t=t??{};const r=!i;i?s=i:(t.mouseWheelSmoothScroll=!1,s=new g.Scrollable({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:t=>a.scheduleAtNextAnimationFrame(a.getWindow(e),t)})),this._options=function(e){const t={lazyRender:void 0!==e.lazyRender&&e.lazyRender,className:void 0!==e.className?e.className:"",useShadows:void 0===e.useShadows||e.useShadows,handleMouseWheel:void 0===e.handleMouseWheel||e.handleMouseWheel,flipAxes:void 0!==e.flipAxes&&e.flipAxes,consumeMouseWheelIfScrollbarIsNeeded:void 0!==e.consumeMouseWheelIfScrollbarIsNeeded&&e.consumeMouseWheelIfScrollbarIsNeeded,alwaysConsumeMouseWheel:void 0!==e.alwaysConsumeMouseWheel&&e.alwaysConsumeMouseWheel,scrollYToX:void 0!==e.scrollYToX&&e.scrollYToX,mouseWheelScrollSensitivity:void 0!==e.mouseWheelScrollSensitivity?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:void 0!==e.fastScrollSensitivity?e.fastScrollSensitivity:5,scrollPredominantAxis:void 0===e.scrollPredominantAxis||e.scrollPredominantAxis,mouseWheelSmoothScroll:void 0===e.mouseWheelSmoothScroll||e.mouseWheelSmoothScroll,listenOnDomNode:void 0!==e.listenOnDomNode?e.listenOnDomNode:null,horizontal:void 0!==e.horizontal?e.horizontal:1,horizontalScrollbarSize:void 0!==e.horizontalScrollbarSize?e.horizontalScrollbarSize:10,horizontalSliderSize:void 0!==e.horizontalSliderSize?e.horizontalSliderSize:0,horizontalHasArrows:void 0!==e.horizontalHasArrows&&e.horizontalHasArrows,vertical:void 0!==e.vertical?e.vertical:1,verticalScrollbarSize:void 0!==e.verticalScrollbarSize?e.verticalScrollbarSize:10,verticalHasArrows:void 0!==e.verticalHasArrows&&e.verticalHasArrows,verticalSliderSize:void 0!==e.verticalSliderSize?e.verticalSliderSize:0,scrollByPage:void 0!==e.scrollByPage&&e.scrollByPage};return t.horizontalSliderSize=void 0!==e.horizontalSliderSize?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=void 0!==e.verticalSliderSize?e.verticalSliderSize:t.verticalScrollbarSize,v.isMac&&(t.className+=" xterm-mac"),t}(t),this._scrollable=s,this._register(this._scrollable.onScroll(e=>{this._handleScroll(e),this._onScroll.fire(e)})),r&&this._register(this._scrollable);const o={handleMouseWheel:e=>this._handleMouseWheel(e),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new d.VerticalScrollbar(this._scrollable,this._options,o)),this._horizontalScrollbar=this._register(new c.HorizontalScrollbar(this._scrollable,this._options,o)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new h.FastDomNode(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,e=>this._handleMouseOver(e)),this._onmouseleave(this._listenOnDomNode,e=>this._handleMouseLeave(e)),this._hideTimeout=this._register(new u.TimeoutTimer),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}dispose(){this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,v.isMac&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){void 0!==e.handleMouseWheel&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),void 0!==e.mouseWheelScrollSensitivity&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),void 0!==e.fastScrollSensitivity&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),void 0!==e.scrollPredominantAxis&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),void 0!==e.horizontal&&(this._options.horizontal=e.horizontal),void 0!==e.vertical&&(this._options.vertical=e.vertical),void 0!==e.horizontalHasArrows&&(this._options.horizontalHasArrows=e.horizontalHasArrows),void 0!==e.verticalHasArrows&&(this._options.verticalHasArrows=e.verticalHasArrows),void 0!==e.horizontalScrollbarSize&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),void 0!==e.verticalScrollbarSize&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),void 0!==e.scrollByPage&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new l.StandardWheelEvent(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=(0,p.dispose)(this._mouseWheelToDispose),e)){const e=e=>{this._handleMouseWheel(new l.StandardWheelEvent(e))};this._mouseWheelToDispose.push(a.addDisposableListener(this._listenOnDomNode,a.eventType.MOUSE_WHEEL,e,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;const t=S.INSTANCE;t.acceptStandardWheelEvent(e);let i=!1;if(e.deltaY||e.deltaX){let s=e.deltaY*this._options.mouseWheelScrollSensitivity,r=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&r+s===0?r=s=0:Math.abs(s)>=Math.abs(r)?r=0:s=0),this._options.flipAxes&&([s,r]=[r,s]);const o=!v.isMac&&e.browserEvent&&e.browserEvent.shiftKey;!this._options.scrollYToX&&!o||r||(r=s,s=0),e.browserEvent&&e.browserEvent.altKey&&(r*=this._options.fastScrollSensitivity,s*=this._options.fastScrollSensitivity);const n=this._scrollable.getFutureScrollPosition();let a={};if(s){const e=50*s,t=n.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(a,t)}if(r){const e=50*r,t=n.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(a,t)}a=this._scrollable.validateScrollPosition(a),(n.scrollLeft!==a.scrollLeft||n.scrollTop!==a.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(a):this._scrollable.setScrollPositionNow(a),i=!0)}let s=i;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){const e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,i=e.scrollLeft>0,s=i?" xterm-shadow-left":"",r=t?" xterm-shadow-top":"",o=i||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${r}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${o}${r}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){this._mouseIsOver||this._isDragging||(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){this._mouseIsOver||this._isDragging||this._hideTimeout.cancelAndSet(()=>this._hide(),500)}}t.SmoothScrollableElement=b},9594(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._arrowPointerDown(e))),this._register(c.addStandardDisposableListener(this.domNode,c.eventType.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._pointerdownRepeatTimer=this._register(new c.WindowIntervalTimer),this._pointerdownScheduleRepeatTimer=this._register(new l.TimeoutTimer)}_arrowPointerDown(e){e.target&&e.target instanceof Element&&(this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,c.getWindow(e))},200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault())}}t.ScrollbarArrow=d},1270(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ScrollbarState=void 0;class i{constructor(e,t,i,s,r,o){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(i),this._arrowSize=Math.round(e),this._visibleSize=s,this._scrollSize=r,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new i(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){const t=Math.round(e);return this._visibleSize!==t&&(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){const t=Math.round(e);return this._scrollSize!==t&&(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){const t=Math.round(e);return this._scrollPosition!==t&&(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setArrowSize(e){const t=Math.round(e);this._arrowSize!==t&&(this._arrowSize=t,this._refreshComputedValues())}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,i,s,r){const o=Math.max(0,i-e),n=Math.max(0,o-2*t),a=s>0&&s>i;if(!a)return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(n),computedSliderRatio:0,computedSliderPosition:0};const h=Math.round(Math.max(20,Math.floor(i*n/s))),l=(n-h)/(s-i),c=r*l;return{computedAvailableSize:Math.round(o),computedIsNeeded:a,computedSliderSize:Math.round(h),computedSliderRatio:l,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){const e=i._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=e.computedAvailableSize,this._computedIsNeeded=e.computedIsNeeded,this._computedSliderSize=e.computedSliderSize,this._computedSliderRatio=e.computedSliderRatio,this._computedSliderPosition=e.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;const t=e-this._arrowSize;let i=this._scrollPosition;return t{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?" xterm-fade":"")))}}t.ScrollbarVisibilityController=o},2650(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;n{s||(s=!0,this._remove(i))}}_remove(e){if(e.prev!==_.Undefined&&e.next!==_.Undefined){const t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===_.Undefined&&e.next===_.Undefined?(this._first=_.Undefined,this._last=_.Undefined):e.next===_.Undefined?(this._last=this._last.prev,this._last.next=_.Undefined):e.prev===_.Undefined&&(this._first=this._first.next,this._first.prev=_.Undefined)}*[Symbol.iterator](){let e=this._first;for(;e!==_.Undefined;)yield e.element,e=e.next}}var f;!function(e){e.TAP="-xterm-gesturetap",e.CHANGE="-xterm-gesturechange",e.START="-xterm-gesturestart",e.END="-xterm-gesturesend",e.CONTEXT_MENU="-xterm-gesturecontextmenu"}(f||(t.EventType=f={}));class p extends l.Disposable{constructor(){super(),this._dispatched=!1,this._targets=new u,this._ignoreTargets=new u,this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;const e=c;this._register(h.addDisposableListener(e.document,"touchstart",e=>this._handleTouchStart(e),{passive:!1})),this._register(h.addDisposableListener(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(h.addDisposableListener(e.document,"touchmove",e=>this._handleTouchMove(e),{passive:!1}))}static addTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._targets.push(e);return(0,l.toDisposable)(t)}static ignoreTarget(e){if(!p.isTouchDevice())return l.Disposable.None;p._instance||(p._instance=new p);const t=p._instance._ignoreTargets.push(e);return(0,l.toDisposable)(t)}static isTouchDevice(){return"ontouchstart"in c||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){const t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let i=0,s=e.targetTouches.length;i=p._holdDelay&&Math.abs(n.initialPageX-d(n.rollingPageX))<30&&Math.abs(n.initialPageY-d(n.rollingPageY))<30){const e=this._newGestureEvent(f.CONTEXT_MENU,n.initialTarget);e.pageX=d(n.rollingPageX),e.pageY=d(n.rollingPageY),this._dispatchEvent(e)}else if(1===s){const t=d(n.rollingPageX),s=d(n.rollingPageY),r=d(n.rollingTimestamps)-n.rollingTimestamps[0],o=t-n.rollingPageX[0],a=s-n.rollingPageY[0],h=[...this._targets].filter(e=>n.initialTarget instanceof Node&&e.contains(n.initialTarget));this._inertia(e,h,i,Math.abs(o)/r,o>0?1:-1,t,Math.abs(a)/r,a>0?1:-1,s)}this._dispatchEvent(this._newGestureEvent(f.END,n.initialTarget)),delete this._activeTouches[o.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){const i=document.createEvent("CustomEvent");return i.initEvent(e,!1,!0),i.initialTarget=t,i.tapCount=0,i}_dispatchEvent(e){if(e.type===f.TAP){const t=(new Date).getTime();let i;i=t-this._lastSetTapCountTime>p._clearTapCountTime?1:2,this._lastSetTapCountTime=t,e.tapCount=i}else e.type!==f.CHANGE&&e.type!==f.CONTEXT_MENU||(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(const t of this._ignoreTargets)if(t.contains(e.initialTarget))return;const t=[];for(const i of this._targets)if(i.contains(e.initialTarget)){let s=0,r=e.initialTarget;for(;r&&r!==i;)s++,r=r.parentElement;t.push([s,i])}t.sort((e,t)=>e[0]-t[0]);for(const[,i]of t)i.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,i,s,r,o,n,a,l){this._handle=h.scheduleAtNextAnimationFrame(e,()=>{const h=Date.now(),c=h-i;let d=0,_=0,u=!0;s+=p._scrollFriction*c,n+=p._scrollFriction*c,s>0&&(u=!1,d=r*s*c),n>0&&(u=!1,_=a*n*c);const v=this._newGestureEvent(f.CHANGE);v.translationX=d,v.translationY=_,t.forEach(e=>e.dispatchEvent(v)),u||this._inertia(e,t,h,s,r,o+d,n,a,l+_)})}_handleTouchMove(e){const t=Date.now();for(let i=0,s=e.changedTouches.length;i3&&(r.rollingPageX.shift(),r.rollingPageY.shift(),r.rollingTimestamps.shift()),r.rollingPageX.push(s.pageX),r.rollingPageY.push(s.pageY),r.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}}t.Gesture=p,p._scrollFriction=-.005,p._holdDelay=700,p._clearTapCountTime=400,n([function(e,t,i){let s=null,r=null;if("function"==typeof i.value?(s="value",r=i.value,0!==r.length&&console.warn("Memoize should only be used in functions with zero parameters")):"function"==typeof i.get&&(s="get",r=i.get),!r||!s)throw new Error("not supported");const o=`$memoize$${t}`;i[s]=function(...e){return this.hasOwnProperty(o)||Object.defineProperty(this,o,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,e)}),this[o]}}],p,"isTouchDevice",null)},8997(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.VerticalScrollbar=void 0;const s=i(8501),r=i(1270);class o extends s.AbstractScrollbar{constructor(e,t,i){const s=e.getScrollDimensions(),o=e.getCurrentScrollPosition(),n=t.verticalHasArrows;super({lazyRender:t.lazyRender,host:i,scrollbarState:new r.ScrollbarState(n?t.verticalScrollbarSize:0,2===t.vertical?0:t.verticalScrollbarSize,0,s.height,s.scrollHeight,o.scrollTop),visibility:t.vertical,extraScrollbarClassName:"xterm-vertical",scrollable:e,scrollByPage:t.scrollByPage}),this._arrowScrollDelta=0,this._setArrows(n,t.verticalScrollbarSize),this._createSlider(0,Math.floor((t.verticalScrollbarSize-t.verticalSliderSize)/2),t.verticalSliderSize,void 0)}_updateSlider(e,t){this.slider.setHeight(e),this.slider.setTop(t)}_renderDomNode(e,t){this.domNode.setWidth(t),this.domNode.setHeight(e),this.domNode.setRight(0),this.domNode.setTop(0)}handleScroll(e){return this._shouldRender=this._handleElementScrollSize(e.scrollHeight)||this._shouldRender,this._shouldRender=this._handleElementScrollPosition(e.scrollTop)||this._shouldRender,this._shouldRender=this._handleElementSize(e.height)||this._shouldRender,this._shouldRender}_pointerDownRelativePosition(e,t){return t}_sliderPointerPosition(e){return e.pageY}_sliderOrthogonalPointerPosition(e){return e.pageX}_updateScrollbarSize(e){this.slider.setWidth(e)}writeScrollPosition(e,t){e.scrollTop=t}_arrowScroll(e){const t=this._scrollable.getCurrentScrollPosition();this._scrollable.setScrollPositionNow({scrollTop:t.scrollTop+e})}_setArrows(e,t){if(this._arrowScrollDelta=t,!this._arrowUp||!this._arrowDown){const e=0;this._arrowUp=this._createArrow({className:"xterm-scra xterm-arrow-up",top:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:e,left:e,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})}if(this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;const i=e?"":"none";this._arrowUp.bgDomNode.style.display=i,this._arrowUp.domNode.style.display=i,this._arrowDown.bgDomNode.style.display=i,this._arrowDown.domNode.style.display=i}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){const t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(2===e.vertical?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}}t.VerticalScrollbar=o},7741(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nt(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseover(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_OVER,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}_onmouseleave(e,t){this._register(a.addDisposableListener(e,a.eventType.MOUSE_LEAVE,i=>t(new h.StandardMouseEvent(a.getWindow(e),i))))}}t.Widget=c},5959(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):!!(this.selectionStart&&this.selectionStart[1]<0)&&(this.selectionStart=[0,0],!0)}}},4792(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const o=i(6501),n=i(4812),a=i(8636);let h=class extends n.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this._register(new a.Emitter),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new d(this._optionsService))}catch{this._measureStrategy=this._register(new c(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h,t.CharSizeService=h=s([r(2,o.IOptionsService)],h);class l extends n.Disposable{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){void 0!==e&&e>0&&void 0!==t&&t>0&&(this._result.width=e,this._result.height=t)}}class c extends l{constructor(e,t,i){super(),this._document=e,this._parentElement=t,this._optionsService=i,this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}}class d extends l{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");const t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;const e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}}},945(e,t,i){var s,r=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},o=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(5451),a=i(8938),h=i(3055),l=i(6501);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let d=s=class{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new h.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;t1){const e=this._getJoinedRanges(s,h,n,t,o);for(let t=0;tthis._screenDprMonitor.setWindow(e))),this._register(s.EventUtils.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register((0,r.addDisposableListener)(this._textarea,"focus",()=>this._isFocused=!0)),this._register((0,r.addDisposableListener)(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}}t.CoreBrowserService=n;class a extends o.Disposable{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new o.MutableDisposable),this._onDprChange=this._register(new s.Emitter),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register((0,o.toDisposable)(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=(0,r.addDisposableListener)(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}}},2136(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.KeyboardService=void 0;const o=i(706),n=i(7241),a=i(9249),h=i(701),l=i(6501);let c=class{constructor(e,t){this._coreService=e,this._optionsService=t}_getWin32InputMode(){return this._win32InputMode??=new a.Win32InputMode,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new n.KittyKeyboard,this._kittyKeyboard}evaluateKeyDown(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!0);const t=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(e,t,e.repeat?2:1,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):(0,o.evaluateKeyboardEvent)(e,this._coreService.decPrivateModes.applicationCursorKeys,h.isMac,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(e){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(e,!1);const t=this._coreService.kittyKeyboard.flags;return this.useKitty&&2&t?this._getKittyKeyboard().evaluate(e,t,3,h.isMac&&this._optionsService.rawOptions.macOptionIsMeta):void 0}get useKitty(){const e=this._coreService.kittyKeyboard.flags;return!(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard||!n.KittyKeyboard.shouldUseProtocol(e))}get useWin32InputMode(){return!(!this._optionsService.rawOptions.vtExtensions?.win32InputMode||!this._coreService.decPrivateModes.win32InputMode)}};t.KeyboardService=c,t.KeyboardService=c=s([r(0,l.ICoreService),r(1,l.IOptionsService)],c)},9820(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.LinkProviderService=void 0;const s=i(4812);class r extends s.Disposable{constructor(){super(),this.linkProviders=[],this._register((0,s.toDisposable)(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{const t=this.linkProviders.indexOf(e);-1!==t&&this.linkProviders.splice(t,1)}}}}t.LinkProviderService=r},8294(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseCoordsService=void 0;const o=i(4159),n=i(5251),a=i(7098);let h=class{constructor(e,t){this._charSizeService=e,this._renderService=t}getCoords(e,t,i,s,r){return(0,n.getCoords)((0,o.getWindow)(t),e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,n.getCoordsRelativeToElement)((0,o.getWindow)(t),e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseCoordsService=h,t.MouseCoordsService=h=s([r(0,a.ICharSizeService),r(1,a.IRenderService)],h)},9784(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AltMouseCursorController=t.MouseService=void 0;const o=i(4159),n=i(6501),a=i(4812),h=i(7098),l=i(2650);let c=class{constructor(e,t,i,s,r,o,n,a,h){this._renderService=e,this._mouseCoordsService=t,this._mouseStateService=i,this._coreService=s,this._bufferService=r,this._optionsService=o,this._selectionService=n,this._logService=a,this._coreBrowserService=h,this._lastEvent=null,this._wheelPartialScroll=0,this._touchScrollAccumulator=0}bindMouse(e,t,i){const{element:s,document:r}=e,n={mouseup:null,wheel:null,mousedrag:null,mousemove:null},h={target:e,focus:i,requestedEvents:n},c={mouseup:e=>this._handleMouseUp(h,e),wheel:e=>this._handleWheel(h,e),mousedrag:e=>this._handleMouseDrag(h,e),mousemove:e=>this._handleMouseMove(h,e)};this._altMouseCursor=new d(s,r,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),t(this._altMouseCursor),t(this._mouseStateService.onProtocolChange(e=>{this._handleProtocolChange(h,c,e)})),t(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(s),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,t((0,a.toDisposable)(()=>{n.mouseup&&r.removeEventListener("mouseup",n.mouseup),n.mousedrag&&r.removeEventListener("mousemove",n.mousedrag)})),t((0,o.addDisposableListener)(s,"mousedown",e=>this._handleMouseDown(h,e))),t((0,o.addDisposableListener)(s,"wheel",e=>this._handlePassiveWheel(h,e),{passive:!1})),t(l.Gesture.addTarget(e.screenElement)),t((0,o.addDisposableListener)(e.screenElement,l.EventType.START,()=>this._handleTouchStart())),t((0,o.addDisposableListener)(e.screenElement,l.EventType.CHANGE,e=>this._handleTouchChange(h,e)))}_sendEvent(e,t){const i=this._mouseCoordsService.getMouseReportCoords(t,e.target.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;const e=t.deltaY;if(0===e)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return!1;r=e<0?0:1,s=4;break;default:return!1}if(void 0===r||void 0===s||s>4)return!1;if(4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!t.altKey)return!1;const o=4!==s&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:!o&&t.altKey,shift:t.shiftKey})}_handleMouseUp(e,t){this._sendEvent(e,t),t.buttons||(e.requestedEvents.mouseup&&e.target.document.removeEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.removeEventListener("mousemove",e.requestedEvents.mousedrag))}_handleWheel(e,t){return this._sendEvent(e,t),t.preventDefault(),t.stopPropagation(),!1}_handleMouseDrag(e,t){t.buttons&&this._sendEvent(e,t)}_handleMouseMove(e,t){t.buttons||this._sendEvent(e,t)}_handleMouseDown(e,t){t.preventDefault(),e.focus(),this._mouseStateService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(t)&&(this._sendEvent(e,t),e.requestedEvents.mouseup&&e.target.document.addEventListener("mouseup",e.requestedEvents.mouseup),e.requestedEvents.mousedrag&&e.target.document.addEventListener("mousemove",e.requestedEvents.mousedrag))}_handlePassiveWheel(e,t){if(!e.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(t))return!1;if(!this._bufferService.buffer.hasScrollback){if(0===t.deltaY)return!1;if(0===this._consumeWheelEvent(t,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr))return t.preventDefault(),t.stopPropagation(),!1;const e=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(e,!0),t.preventDefault(),t.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(e,t){t.preventDefault(),t.stopPropagation(),e.requestedEvents.wheel?this._handleTouchScrollAsWheel(e,t):this._bufferService.buffer.hasScrollback?e.target.handleTouchScroll?.(t.translationY):this._handleTouchScrollAsKeys(t)}_handleTouchScrollAsKeys(e){const t=this._renderService?.dimensions.css.cell.height;if(!t)return;this._touchScrollAccumulator-=e.translationY;const i=Math.trunc(this._touchScrollAccumulator/t);if(0===i)return;this._touchScrollAccumulator-=i*t;const s=""+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(i<0?"A":"B");for(let e=0;e0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(r*=this._bufferService.rows),r}_triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._mouseStateService.isPixelEncoding))return!1;if(!this._mouseStateService.restrictMouseEvent(e))return!1;const t=this._mouseStateService.encodeMouseEvent(e);return t&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}_explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.MouseService=c,t.MouseService=c=s([r(0,h.IRenderService),r(1,h.IMouseCoordsService),r(2,n.IMouseStateService),r(3,n.ICoreService),r(4,n.IBufferService),r(5,n.IOptionsService),r(6,h.ISelectionService),r(7,n.ILogService),r(8,h.ICoreBrowserService)],c);class d{constructor(e,t,i){this._element=e,this._document=t,this._isActive=i,this._listeners=new a.MutableDisposable}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;const e=new a.DisposableStore,t=e=>this.syncFromModifier(e);e.add((0,o.addDisposableListener)(this._document,"keydown",t)),e.add((0,o.addDisposableListener)(this._document,"keyup",t)),e.add((0,o.addDisposableListener)(this._element,"mousemove",t));const i=this._element.ownerDocument?.defaultView;i&&e.add((0,o.addDisposableListener)(i,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=e}resetClass(){this._updateClass(!1)}syncFromModifier(e){this._isActive()&&this._updateClass(e.getModifierState("Alt"))}_updateClass(e){e?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}}t.AltMouseCursorController=d},5783(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const o=i(4852),n=i(7098),a=i(4812),h=i(6168),l=i(6501),c=i(8636);let d=class extends a.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,n,l,d,u,f){super(),this._rowCount=e,this._optionsService=i,this._logService=s,this._charSizeService=r,this._coreService=n,this._coreBrowserService=u,this._renderer=this._register(new a.MutableDisposable),this._observerDisposable=this._register(new a.MutableDisposable),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new c.Emitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new c.Emitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new c.Emitter),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new c.Emitter),this.onRefreshRequest=this._onRefreshRequest.event,this._pausedResizeTask=this._register(new h.DebouncedIdleTask(this._logService)),this._renderDebouncer=new o.RenderDebouncer((e,t)=>this._renderRows(e,t),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new _(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register((0,a.toDisposable)(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(d.onResize(()=>this._fullRefresh())),this._register(d.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(d.cols,d.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(d.buffer.y,d.buffer.y,void 0,!0))),this._register(f.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(e=>this._registerIntersectionObserver(e,t)))}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){const i=new e.IntersectionObserver(e=>this._handleIntersectionChange(e[e.length-1]),{threshold:0});this._observerDisposable.value=(0,a.toDisposable)(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=i,i.observe(t)}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1,s=!1){if(this._isPaused)return void(this._needsFullRefresh=!0);if(this._coreService.decPrivateModes.synchronizedOutput)return void this._syncOutputHandler.bufferRows(e,t);const r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),s||(this._isNextRenderRedrawOnly=!1),i?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){this._renderer.value&&(this._coreService.decPrivateModes.synchronizedOutput?this._syncOutputHandler.bufferRows(e,t):(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0))}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(e=>this.refreshRows(e.start,e.end,e.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,i){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,this._renderer.value?.handleSelectionChanged(e,t,i)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};t.RenderService=d,t.RenderService=d=s([r(2,l.IOptionsService),r(3,l.ILogService),r(4,n.ICharSizeService),r(5,l.ICoreService),r(6,l.IDecorationService),r(7,l.IBufferService),r(8,n.ICoreBrowserService),r(9,n.IThemeService)],d);class _{constructor(e,t,i){this._coreBrowserService=e,this._coreService=t,this._onTimeout=i,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;const e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){void 0!==this._timeout&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}}},2079(e,t,i){var s,r=this&&this.__createBinding||(Object.create?function(e,t,i,s){void 0===s&&(s=i);var r=Object.getOwnPropertyDescriptor(t,i);r&&!("get"in r?!t.__esModule:r.writable||r.configurable)||(r={enumerable:!0,get:function(){return t[i]}}),Object.defineProperty(e,s,r)}:function(e,t,i,s){void 0===s&&(s=i),e[s]=t[i]}),o=this&&this.__setModuleDefault||(Object.create?function(e,t){Object.defineProperty(e,"default",{enumerable:!0,value:t})}:function(e,t){e.default=t}),n=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},a=this&&this.__importStar||(s=function(e){return s=Object.getOwnPropertyNames||function(e){var t=[];for(var i in e)Object.prototype.hasOwnProperty.call(e,i)&&(t[t.length]=i);return t},s(e)},function(e){if(e&&e.__esModule)return e;var t={};if(null!=e)for(var i=s(e),n=0;nthis._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(e=>this._handleTrim(e)),this._register(this._bufferService.buffers.onBufferActivate(e=>this._handleBufferActivate(e))),this.enable(),this._model=new d.SelectionModel(this._bufferService),this._activeSelectionMode=0,this._register((0,u.toDisposable)(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(e=>{e.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(b," ")).join(f.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),f.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){const i=this._linkifier.currentLink?.link?.range;if(i)return this._model.selectionStart=[i.start.x-1,i.start.y-1],this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const s=this._getMouseBufferCoords(e);return!!s&&(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,l.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:f.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(2===e.button&&this.hasSelection||0!==e.button||this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){const t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);const i=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);i&&i.length!==this._model.selectionStart[0]&&0===i.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return(!this._optionsService.rawOptions.mouseEventsRequireAlt||!this._mouseStateService.areMouseEventsActive)&&e.altKey&&!(f.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,c.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(e=>this._handleTrim(e))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,o=r.lines.get(e[1]);if(!o)return;const n=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(o,e[0]),h=a;const l=e[0]-a;let c=0,d=0,_=0,u=0;if(" "===n.charAt(a)){for(;a>0&&" "===n.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(o.loadCell(t-1,this._workCell));){o.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(c++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+l-c+_,p=Math.min(this._bufferService.cols,h-a+c+d-_-u);if(t||""!==n.slice(a,h).trim()){if(i&&0===f&&32!==o.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&o.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,p+=e}}}if(s&&f+p===this._bufferService.cols&&32!==o.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if(t?.isWrapped&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(p+=t.length)}}return{start:f,length:p}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,p.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=w,t.SelectionService=w=n([h(3,g.IBufferService),h(4,g.ICoreService),h(5,_.IMouseCoordsService),h(6,g.IOptionsService),h(7,g.IMouseStateService),h(8,_.IRenderService),h(9,_.ICoreBrowserService)],w)},7098(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IKeyboardService=t.ILinkProviderService=t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.IMouseCoordsService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(6201);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseCoordsService=(0,s.createDecorator)("MouseCoordsService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService"),t.ILinkProviderService=(0,s.createDecorator)("LinkProviderService"),t.IKeyboardService=(0,s.createDecorator)("KeyboardService")},9078(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=void 0;const o=i(7174),n=i(9302),a=i(4103),h=i(4812),l=i(6501),c=i(8636),d=a.css.toColor("#ffffff"),_=a.css.toColor("#000000"),u=a.css.toColor("#ffffff"),f=_,p={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},v=d;let g=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new o.ColorContrastCache,this._halfContrastCache=new o.ColorContrastCache,this._onChangeColors=this._register(new c.Emitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:d,background:_,cursor:u,cursorAccent:f,selectionForeground:void 0,selectionBackgroundTransparent:p,selectionBackgroundOpaque:a.color.blend(_,p),selectionInactiveBackgroundTransparent:p,selectionInactiveBackgroundOpaque:a.color.blend(_,p),scrollbarSliderBackground:a.color.opacity(d,.2),scrollbarSliderHoverBackground:a.color.opacity(d,.4),scrollbarSliderActiveBackground:a.color.opacity(d,.5),overviewRulerBorder:d,ansi:n.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}_setTheme(e={}){const t=this._colors;if(t.foreground=m(e.foreground,d),t.background=m(e.background,_),t.cursor=a.color.blend(t.background,m(e.cursor,u)),t.cursorAccent=a.color.blend(t.background,m(e.cursorAccent,f)),t.selectionBackgroundTransparent=m(e.selectionBackground,p),t.selectionBackgroundOpaque=a.color.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=m(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=a.color.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?m(e.selectionForeground,a.NULL_COLOR):void 0,t.selectionForeground===a.NULL_COLOR&&(t.selectionForeground=void 0),a.color.isOpaque(t.selectionBackgroundTransparent)){const e=.3;t.selectionBackgroundTransparent=a.color.opacity(t.selectionBackgroundTransparent,e)}if(a.color.isOpaque(t.selectionInactiveBackgroundTransparent)){const e=.3;t.selectionInactiveBackgroundTransparent=a.color.opacity(t.selectionInactiveBackgroundTransparent,e)}if(t.scrollbarSliderBackground=m(e.scrollbarSliderBackground,a.color.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=m(e.scrollbarSliderHoverBackground,a.color.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=m(e.scrollbarSliderActiveBackground,a.color.opacity(t.foreground,.5)),t.overviewRulerBorder=m(e.overviewRulerBorder,v),t.ansi=n.DEFAULT_ANSI_COLORS.slice(),t.ansi[0]=m(e.black,n.DEFAULT_ANSI_COLORS[0]),t.ansi[1]=m(e.red,n.DEFAULT_ANSI_COLORS[1]),t.ansi[2]=m(e.green,n.DEFAULT_ANSI_COLORS[2]),t.ansi[3]=m(e.yellow,n.DEFAULT_ANSI_COLORS[3]),t.ansi[4]=m(e.blue,n.DEFAULT_ANSI_COLORS[4]),t.ansi[5]=m(e.magenta,n.DEFAULT_ANSI_COLORS[5]),t.ansi[6]=m(e.cyan,n.DEFAULT_ANSI_COLORS[6]),t.ansi[7]=m(e.white,n.DEFAULT_ANSI_COLORS[7]),t.ansi[8]=m(e.brightBlack,n.DEFAULT_ANSI_COLORS[8]),t.ansi[9]=m(e.brightRed,n.DEFAULT_ANSI_COLORS[9]),t.ansi[10]=m(e.brightGreen,n.DEFAULT_ANSI_COLORS[10]),t.ansi[11]=m(e.brightYellow,n.DEFAULT_ANSI_COLORS[11]),t.ansi[12]=m(e.brightBlue,n.DEFAULT_ANSI_COLORS[12]),t.ansi[13]=m(e.brightMagenta,n.DEFAULT_ANSI_COLORS[13]),t.ansi[14]=m(e.brightCyan,n.DEFAULT_ANSI_COLORS[14]),t.ansi[15]=m(e.brightWhite,n.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const i=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;ssetTimeout(t,e))},t.disposableTimeout=function(e,t=0,i){const r=setTimeout(()=>{e(),i&&o.dispose()},t),o=(0,s.toDisposable)(()=>{clearTimeout(r)});return i?.add(o),o};const s=i(4812);t.TimeoutTimer=class{constructor(){this._token=-1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){-1!==this._token&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");-1===this._token&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},t.MicrotaskTimer=class{constructor(){this._isScheduled=!1,this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(e){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,e())}))}},t.IntervalTimer=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(e,t,i=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();const s=i.setInterval(()=>{e()},t);this._disposable={dispose:()=>{i.clearInterval(s),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}}},5639(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(4812),r=i(8636);class o extends s.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this._register(new r.Emitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this._register(new r.Emitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this._register(new r.Emitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s>>0},e.toColor=function(t,i,s,r){return{css:e.toCss(t,i,s,r),rgba:e.toRgba(t,i,s,r)}}}(n||(t.channels=n={})),function(e){function t(e,t){return o=Math.round(255*t),[i,s,r]=c.toChannels(e.rgba),{css:n.toCss(i,s,r,o),rgba:n.toRgba(i,s,r,o)}}e.blend=function(e,t){if(o=(255&t.rgba)/255,1===o)return{css:t.css,rgba:t.rgba};const a=t.rgba>>24&255,h=t.rgba>>16&255,l=t.rgba>>8&255,c=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),{css:n.toCss(i,s,r),rgba:n.toRgba(i,s,r)}},e.isOpaque=function(e){return!(255&~e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=c.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return n.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[i,s,r]=c.toChannels(t),{css:n.toCss(i,s,r),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return o=255&e.rgba,t(e,o*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(a||(t.color=a={})),function(e){let t,a;try{const e=document.createElement("canvas");e.width=1,e.height=1;const i=e.getContext("2d",{willReadFrequently:!0});i&&(t=i,t.globalCompositeOperation="copy",a=t.createLinearGradient(0,0,1,1))}catch{}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),n.toColor(i,s,r);case 5:return i=parseInt(e.slice(1,2).repeat(2),16),s=parseInt(e.slice(2,3).repeat(2),16),r=parseInt(e.slice(3,4).repeat(2),16),o=parseInt(e.slice(4,5).repeat(2),16),n.toColor(i,s,r,o);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const h=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(h)return i=parseInt(h[1],10),s=parseInt(h[2],10),r=parseInt(h[3],10),o=Math.round(255*(void 0===h[5]?1:parseFloat(h[5]))),n.toColor(i,s,r,o);if("transparent"===e)return{css:"transparent",rgba:0};if(!t||!a)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=a,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[i,s,r,o]=t.getImageData(0,0,1,1).data,255!==o)throw new Error("css.toColor: Unsupported css format");return{rgba:n.toRgba(i,s,r,o),css:e}}}(h||(t.css=h={})),function(e){function t(e,t,i){const s=e/255,r=t/255,o=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(l||(t.rgb=l={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c0||a>0||h>0);)n-=Math.max(0,Math.ceil(.1*n)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));return(n<<24|a<<16|h<<8|255)>>>0}function a(e,t,i){const s=e>>24&255,r=e>>16&255,o=e>>8&255;let n=t>>24&255,a=t>>16&255,h=t>>8&255,c=_(l.relativeLuminance2(n,a,h),l.relativeLuminance2(s,r,o));for(;c>>0}e.blend=function(e,t){if(o=(255&t)/255,1===o)return t;const a=t>>24&255,h=t>>16&255,l=t>>8&255,c=e>>24&255,d=e>>16&255,_=e>>8&255;return i=c+Math.round((a-c)*o),s=d+Math.round((h-d)*o),r=_+Math.round((l-_)*o),n.toRgba(i,s,r)},e.ensureContrastRatio=function(e,i,s){const r=l.relativeLuminance(e>>8),o=l.relativeLuminance(i>>8);if(_(r,o)>8));if(n_(r,l.relativeLuminance(t>>8))?o:t}return o}const n=a(e,i,s),h=_(r,l.relativeLuminance(n>>8));if(h_(r,l.relativeLuminance(o>>8))?n:o}return n}},e.reduceLuminance=t,e.increaseLuminance=a,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]}}(c||(t.rgba=c={}))},5777(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(6501),r=i(6025),o=i(7276),n=i(9640),a=i(56),h=i(4071),l=i(6478),c=i(7428),d=i(6415),_=i(5746),u=i(5882),f=i(2486),p=i(3562),v=i(8811),g=i(8636),m=i(4812);let S=!1;class b extends m.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new g.Emitter),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this._register(new m.MutableDisposable),this._onBinary=this._register(new g.Emitter),this.onBinary=this._onBinary.event,this._onData=this._register(new g.Emitter),this.onData=this._onData.event,this._onLineFeed=this._register(new g.Emitter),this.onLineFeed=this._onLineFeed.event,this._onRender=this._register(new g.Emitter),this.onRender=this._onRender.event,this._onResize=this._register(new g.Emitter),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new g.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new g.Emitter),this._instantiationService=new r.InstantiationService,this.optionsService=this._register(new a.OptionsService(e)),this._instantiationService.setService(s.IOptionsService,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(s.ILogService,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(n.BufferService)),this._instantiationService.setService(s.IBufferService,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(h.CoreService)),this._instantiationService.setService(s.ICoreService,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(l.MouseStateService)),this._instantiationService.setService(s.IMouseStateService,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(d.UnicodeService)),this.unicodeService.register(new c.UnicodeV6),this._instantiationService.setService(s.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(_.CharsetService),this._instantiationService.setService(s.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(v.OscLinkService),this._instantiationService.setService(s.IOscLinkService,this._oscLinkService),this._inputHandler=this._register(new f.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(g.EventUtils.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(g.EventUtils.forward(this._bufferService.onResize,this._onResize)),this._register(g.EventUtils.forward(this.coreService.onData,this._onData)),this._register(g.EventUtils.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new p.WriteBuffer((e,t)=>this._inputHandler.parse(e,t))),this._register(g.EventUtils.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=s.LogLevelEnum.WARN&&!S&&(this._logService.warn("writeSync is unreliable and will be removed soon."),S=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.backend&&void 0!==t.buildNumber&&(e=!!("conpty"===t.backend&&t.buildNumber<21376)),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(u.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>((0,u.updateWindowsModeWrappedState)(this._bufferService),!1))),this._windowsWrappingHeuristics.value=(0,m.toDisposable)(()=>{for(const t of e)t.dispose()})}}}t.CoreTerminal=b},8636(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EventUtils=t.Emitter=void 0;const s=i(4812);var r;t.Emitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=(e,t,i)=>{if(this._disposed)return(0,s.toDisposable)(()=>{});const r={fn:e,thisArgs:t};this._listeners.push(r);const o=(0,s.toDisposable)(()=>{const e=this._listeners.indexOf(r);-1!==e&&this._listeners.splice(e,1)});return i&&(Array.isArray(i)?i.push(o):i.add(o)),o}),this._event}fire(e){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{const{fn:t,thisArgs:i}=this._listeners[0];return void t.call(i,e)}default:{const t=this._listeners.slice();for(const{fn:i,thisArgs:s}of t)i.call(s,e)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},function(e){e.forward=function(e,t){return e(e=>t.fire(e))},e.map=function(e,t){return(i,s,r)=>e(e=>i.call(s,t(e)),void 0,r)},e.any=function(...e){return(t,i,r)=>{const o=new s.DisposableStore;for(const s of e)o.add(s(e=>t.call(i,e)));return r&&(Array.isArray(r)?r.push(o):r.add(o)),o}},e.runAndSubscribe=function(e,t,i){return t(i),e(e=>t(e))}}(r||(t.EventUtils=r={}))},2486(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0,t.isValidColorIndex=L;const o=i(6760),n=i(6717),a=i(4812),h=i(726),l=i(6107),c=i(8938),d=i(3055),_=i(5451),u=i(6501),f=i(6415),p=i(1346),v=i(9823),g=i(2607),m=i(8693),S=i(8636),b=i(7804),w={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function y(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var C;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(C||(t.WindowsOptionsReportType=C={}));let k=0;class D extends a.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,a,c,d,_=new n.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=a,this._mouseStateService=c,this._unicodeService=d,this._parser=_,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new h.StringToUtf32,this._utf8Decoder=new h.Utf8ToUtf32,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this._register(new S.Emitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new S.Emitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new S.Emitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new S.Emitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new S.Emitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new S.Emitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new S.Emitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new S.Emitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new S.Emitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new S.Emitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new S.Emitter),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new S.Emitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new S.Emitter),this.onColor=this._onColor.event,this._onRequestColorSchemeQuery=this._register(new S.Emitter),this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new E(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(e=>this._activeBuffer=e.activeBuffer)),this._parser.setCsiHandlerFallback((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})}),this._parser.setEscHandlerFallback(e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})}),this._parser.setExecuteHandlerFallback(e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})}),this._parser.setOscHandlerFallback((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})}),this._parser.setDcsHandlerFallback((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setApcHandlerFallback((e,t,i)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(e),action:t,payload:i})}),this._parser.setPrintHandler((e,t,i)=>this.print(e,t,i)),this._parser.registerCsiHandler({final:"@"},e=>this.insertChars(e)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},e=>this.scrollLeft(e)),this._parser.registerCsiHandler({final:"A"},e=>this.cursorUp(e)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},e=>this.scrollRight(e)),this._parser.registerCsiHandler({final:"B"},e=>this.cursorDown(e)),this._parser.registerCsiHandler({final:"C"},e=>this.cursorForward(e)),this._parser.registerCsiHandler({final:"D"},e=>this.cursorBackward(e)),this._parser.registerCsiHandler({final:"E"},e=>this.cursorNextLine(e)),this._parser.registerCsiHandler({final:"F"},e=>this.cursorPrecedingLine(e)),this._parser.registerCsiHandler({final:"G"},e=>this.cursorCharAbsolute(e)),this._parser.registerCsiHandler({final:"H"},e=>this.cursorPosition(e)),this._parser.registerCsiHandler({final:"I"},e=>this.cursorForwardTab(e)),this._parser.registerCsiHandler({final:"J"},e=>this.eraseInDisplay(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},e=>this.eraseInDisplay(e,!0)),this._parser.registerCsiHandler({final:"K"},e=>this.eraseInLine(e,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},e=>this.eraseInLine(e,!0)),this._parser.registerCsiHandler({final:"L"},e=>this.insertLines(e)),this._parser.registerCsiHandler({final:"M"},e=>this.deleteLines(e)),this._parser.registerCsiHandler({final:"P"},e=>this.deleteChars(e)),this._parser.registerCsiHandler({final:"S"},e=>this.scrollUp(e)),this._parser.registerCsiHandler({final:"T"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"X"},e=>this.eraseChars(e)),this._parser.registerCsiHandler({final:"Z"},e=>this.cursorBackwardTab(e)),this._parser.registerCsiHandler({final:"^"},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:"`"},e=>this.charPosAbsolute(e)),this._parser.registerCsiHandler({final:"a"},e=>this.hPositionRelative(e)),this._parser.registerCsiHandler({final:"b"},e=>this.repeatPrecedingCharacter(e)),this._parser.registerCsiHandler({final:"c"},e=>this.sendDeviceAttributesPrimary(e)),this._parser.registerCsiHandler({prefix:">",final:"c"},e=>this.sendDeviceAttributesSecondary(e)),this._parser.registerCsiHandler({final:"d"},e=>this.linePosAbsolute(e)),this._parser.registerCsiHandler({final:"e"},e=>this.vPositionRelative(e)),this._parser.registerCsiHandler({final:"f"},e=>this.hVPosition(e)),this._parser.registerCsiHandler({final:"g"},e=>this.tabClear(e)),this._parser.registerCsiHandler({final:"h"},e=>this.setMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"h"},e=>this.setModePrivate(e)),this._parser.registerCsiHandler({final:"l"},e=>this.resetMode(e)),this._parser.registerCsiHandler({prefix:"?",final:"l"},e=>this.resetModePrivate(e)),this._parser.registerCsiHandler({final:"m"},e=>this.charAttributes(e)),this._parser.registerCsiHandler({final:"n"},e=>this.deviceStatus(e)),this._parser.registerCsiHandler({prefix:"?",final:"n"},e=>this.deviceStatusPrivate(e)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},e=>this.softReset(e)),this._parser.registerCsiHandler({prefix:">",final:"q"},e=>this.sendXtVersion(e)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},e=>this.setCursorStyle(e)),this._parser.registerCsiHandler({final:"r"},e=>this.setScrollRegion(e)),this._parser.registerCsiHandler({final:"s"},e=>this.saveCursor(e)),this._parser.registerCsiHandler({final:"t"},e=>this.windowOptions(e)),this._parser.registerCsiHandler({final:"u"},e=>this.restoreCursor(e)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},e=>this.insertColumns(e)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},e=>this.deleteColumns(e)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},e=>this.selectProtected(e)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},e=>this.requestMode(e,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},e=>this.requestMode(e,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},e=>this.kittyKeyboardSet(e)),this._parser.registerCsiHandler({prefix:"?",final:"u"},e=>this.kittyKeyboardQuery(e)),this._parser.registerCsiHandler({prefix:">",final:"u"},e=>this.kittyKeyboardPush(e)),this._parser.registerCsiHandler({prefix:"<",final:"u"},e=>this.kittyKeyboardPop(e)),this._parser.setExecuteHandler("",()=>this.bell()),this._parser.setExecuteHandler("\n",()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler("\t",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("„",()=>this.index()),this._parser.setExecuteHandler("…",()=>this.nextLine()),this._parser.setExecuteHandler("ˆ",()=>this.tabSet()),this._parser.registerOscHandler(0,new p.OscHandler(e=>(this.setTitle(e),this.setIconName(e),!0))),this._parser.registerOscHandler(1,new p.OscHandler(e=>this.setIconName(e))),this._parser.registerOscHandler(2,new p.OscHandler(e=>this.setTitle(e))),this._parser.registerOscHandler(4,new p.OscHandler(e=>this.setOrReportIndexedColor(e))),this._parser.registerOscHandler(8,new p.OscHandler(e=>this.setHyperlink(e))),this._parser.registerOscHandler(10,new p.OscHandler(e=>this.setOrReportFgColor(e))),this._parser.registerOscHandler(11,new p.OscHandler(e=>this.setOrReportBgColor(e))),this._parser.registerOscHandler(12,new p.OscHandler(e=>this.setOrReportCursorColor(e))),this._parser.registerOscHandler(104,new p.OscHandler(e=>this.restoreIndexedColor(e))),this._parser.registerOscHandler(110,new p.OscHandler(e=>this.restoreFgColor(e))),this._parser.registerOscHandler(111,new p.OscHandler(e=>this.restoreBgColor(e))),this._parser.registerOscHandler(112,new p.OscHandler(e=>this.restoreCursorColor(e))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},()=>this.selectCharset("("+e)),this._parser.registerEscHandler({intermediates:")",final:e},()=>this.selectCharset(")"+e)),this._parser.registerEscHandler({intermediates:"*",final:e},()=>this.selectCharset("*"+e)),this._parser.registerEscHandler({intermediates:"+",final:e},()=>this.selectCharset("+"+e)),this._parser.registerEscHandler({intermediates:"-",final:e},()=>this.selectCharset("-"+e)),this._parser.registerEscHandler({intermediates:".",final:e},()=>this.selectCharset("."+e)),this._parser.registerEscHandler({intermediates:"/",final:e},()=>this.selectCharset("/"+e));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(e=>(this._logService.error("Parsing error: ",e),e)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new v.DcsHandler((e,t)=>this.requestStatusString(e,t)))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=u.LogLevelEnum.WARN){let t;const i=new Promise((e,i)=>{t=setTimeout(()=>i("#SLOW_TIMEOUT"),5e3)});Promise.race([e,i]).then(()=>{void 0!==t&&clearTimeout(t)},e=>{if(void 0!==t&&clearTimeout(t),"#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,o=0;const n=this._parseStack.paused;if(n){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(o=this._parseStack.position+131072)}if(this._logService.logLevel<=u.LogLevelEnum.DEBUG&&this._logService.debug("parsing data "+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,e=>String.fromCharCode(e)).join("")}"`)),this._logService.logLevel===u.LogLevelEnum.TRACE&&this._logService.trace("parsing data (codes)","string"==typeof e?e.split("").map(e=>e.charCodeAt(0)):e),this._parseBuffer.length131072)for(let t=o;t0&&2===p.getWidth(this._activeBuffer.x-1)&&p.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let v=this._parser.precedingJoinState;for(let g=t;ga)if(d){const e=p;let t=this._activeBuffer.x-m;if(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),p=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!p)return;for(m>0&&p instanceof l.BufferLine&&p.copyCellsFrom(e,t,0,m,!1);t=0;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(_&&(p.insertCells(this._activeBuffer.x,r-m,this._activeBuffer.getNullCell(u)),2===p.getWidth(a-1)&&p.setCellFromCodepoint(a-1,c.NULL_CELL_CODE,c.NULL_CELL_WIDTH,u)),p.setCellFromCodepoint(this._activeBuffer.x++,s,r,u),r>0)for(;--r;)p.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=v,this._activeBuffer.x0&&0===p.getWidth(this._activeBuffer.x)&&!p.hasContent(this._activeBuffer.x)&&p.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,e=>!y(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new v.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new g.ApcHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const o=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);o&&(o.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),r),s&&(o.isWrapped=!1))}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols){const e=this._activeBuffer.lines.get(i+1);e&&(e.isWrapped=!1)}for(;i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(i=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,i-1);i--;){const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+i);if(e?.getTrimmedLength())break}for(;i>=0;i--)this._bufferService.scroll(this._eraseAttrData())}else{for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0)}break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let h=a;for(let e=1;e0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`P>|xterm.js(${b.XTERM_VERSION})\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(o.triggerDataEvent(`[${t?"":"?"}${e};${i}$y`),!0),_=e=>e?1:2,u=e.params[0];return t?d(u,2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(c.convertEol):0):1===u?d(u,_(i.applicationCursorKeys)):3===u?d(u,c.windowOptions.setWinLines?80===a?2:132===a?1:0:0):6===u?d(u,_(i.origin)):7===u?d(u,_(i.wraparound)):8===u?d(u,3):9===u?d(u,_("X10"===s)):12===u?d(u,_(c.cursorBlink)):25===u?d(u,_(!o.isCursorHidden)):45===u?d(u,_(i.reverseWraparound)):66===u?d(u,_(i.applicationKeypad)):67===u?d(u,4):1e3===u?d(u,_("VT200"===s)):1002===u?d(u,_("DRAG"===s)):1003===u?d(u,_("ANY"===s)):1004===u?d(u,_(i.sendFocus)):1005===u?d(u,4):1006===u?d(u,_("SGR"===r)):1015===u?d(u,4):1016===u?d(u,_("SGR_PIXELS"===r)):1048===u?d(u,1):47===u||1047===u||1049===u?d(u,_(h===l)):2004===u?d(u,_(i.bracketedPasteMode)):2026===u?d(u,_(i.synchronizedOutput)):9001===u&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?d(u,_(i.win32InputMode)):d(u,0)}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=_.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-67108864,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,o=0;do{if(s[o+r]=e.params[t+o],e.hasSubParams(t+o)){const i=e.getSubParams(t+o);let n=0;do{5===s[1]&&(r=1),s[o+n+1+r]=i[n]}while(++n=2||2===s[1]&&o+r>=5)break;s[1]&&(r=1)}while(++o+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-67108864,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-67108864,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-67108864,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-67108864,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:221===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.fg&=-134217729:222===i&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??1)?s.bg&=-134217729:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("");break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[${e};${t}R`)}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`[?${e};${t}R`);break;case 15:case 25:case 26:case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??1)&&this._onRequestColorSchemeQuery.fire()}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=0===e.length?1:e.params[0];if(0===t)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar"}const e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!y(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(C.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(C.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let e=0;e1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e,10);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.indexOf(";");if(-1===t)return!0;const i=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(i,s):!i.trim()&&this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex(e=>e.startsWith("id="));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new d.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${e}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(i){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=e.params[0]||0,i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;return s.length>=16&&s.shift(),s.push(i.flags),i.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;const t=Math.max(1,e.params[0]||1),i=this._coreService.kittyKeyboard,s=this._bufferService.buffer===this._bufferService.buffers.alt?i.altStack:i.mainStack;for(let e=0;e0;e++)i.flags=s.pop();return 0===s.length&&t>0&&(i.flags=0),!0}}t.InputHandler=D;let E=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(k=e,e=t,t=k),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}E=s([r(0,u.IBufferService)],E)},4812(e,t){function i(e){return{dispose:e}}function s(e){if(!e)return e;if(Array.isArray(e)){for(const t of e)t.dispose();return[]}return e.dispose(),e}Object.defineProperty(t,"__esModule",{value:!0}),t.MutableDisposable=t.Disposable=t.DisposableStore=void 0,t.toDisposable=i,t.dispose=s,t.combinedDisposable=function(...e){return i(()=>s(e))};class r{constructor(){this._disposables=new Set,this._isDisposed=!1}get isDisposed(){return this._isDisposed}add(e){return this._isDisposed?e.dispose():this._disposables.add(e),e}dispose(){if(!this._isDisposed){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.clear()}}clear(){for(const e of this._disposables)e.dispose();this._disposables.clear()}}t.DisposableStore=r;class o{constructor(){this._store=new r}dispose(){this._store.dispose()}_register(e){return this._store.add(e)}}t.Disposable=o,o.None=Object.freeze({dispose(){}}),t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,this._value?.dispose(),this._value=void 0}}},7710(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,o){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,o)}get(e,t,i,s){return this._data.get(e,t)?.get(i,s)}clear(){this._data.clear()}}},701(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isMac=t.isSafari=t.isLegacyEdge=t.isChrome=t.isFirefox=t.isNode=void 0,t.getZoomFactor=function(e){return 1},t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1],10)},t.isNode=!("undefined"==typeof process||!("title"in process)||"undefined"!=typeof navigator&&!navigator.userAgent.startsWith("Node.js/"));const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isChrome=i.includes("Chrome"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},3087(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;const s=i(6168);let r=0;t.SortedList=class{constructor(e,t){this._getKey=e,this._array=[],this._insertedValues=[],this._isFlushingInserted=!1,this._deletedIndices=[],this._isFlushingDeleted=!1,this._flushInsertedTask=new s.IdleTaskQueue(t),this._flushDeletedTask=new s.IdleTaskQueue(t)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),0===this._insertedValues.length&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){const e=this._insertedValues.sort((e,t)=>this._getKey(e)-this._getKey(t));let t=0,i=0;const s=new Array(this._array.length+this._insertedValues.length);for(let r=0;r=this._array.length||this._getKey(e[t])<=this._getKey(this._array[i])?(s[r]=e[t],t++):s[r]=this._array[i++];this._array=s,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(this._deleteAtKey(e,t))return!0;if(0===this._deletedIndices.length)return!1;return this._flushCleanupDeleted(),this._deleteAtKey(e,t)}_deleteAtKey(e,t){if(r=this._search(t),-1===r)return!1;if(this._getKey(this._array[r])!==t)return!1;do{if(this._array[r]===e)return 0===this._deletedIndices.length&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(r),!0}while(++re-t);let t=0;const i=new Array(this._array.length-e.length);let s=0;for(let r=0;r0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),0!==this._array.length&&(r=this._search(e),!(r<0||r>=this._array.length)&&this._getKey(this._array[r])===e))do{yield this._array[r]}while(++r=this._array.length)&&this._getKey(this._array[r])===e))do{t(this._array[r])}while(++r=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},4220(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.LimitedStringBuilder=t.StringBuilder=void 0;class i{constructor(){this._chunks=[],this._length=0}get length(){return this._length}reset(){this._chunks.length=0,this._length=0}append(e){this._chunks.push(e),this._length+=e.length}toString(){return this._chunks.join("")}}t.StringBuilder=i,t.LimitedStringBuilder=class{constructor(e){this._limit=e,this._builder=new i}get length(){return this._builder.length}get limit(){return this._limit}reset(){this._builder.reset()}append(e){return this._builder.append(e),this._builder.length>this._limit&&(this._builder.reset(),!0)}toString(){return this._builder.toString()}}},6168(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;class i{constructor(e){this._tasks=[],this._i=0,this._logService=e}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ii)return r-t<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),void this._start();r=i}this.clear()}}class s extends i{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}}t.PriorityTaskQueue=s,t.IdleTaskQueue="requestIdleCallback"in globalThis?class extends i{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:s,t.DebouncedIdleTask=class{constructor(e){this._queue=new t.IdleTaskQueue(e)}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}dispose(){this._queue.clear()}}},7804(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.XTERM_VERSION=void 0,t.XTERM_VERSION="6.1.0-beta.287"},5882(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=t?.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)};const s=i(8938)},5451(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return!(50331648&~this.fg)}isBgRGB(){return!(50331648&~this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return!(50331648&this.fg)}isBgDefault(){return!(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&~this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?!(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){const e=(3758096384&this._ext)>>29;return e<0?4294967288^e:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},1073(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(5639),r=i(4812),o=i(6168),n=i(5451),a=i(6107),h=i(3326),l=i(732),c=i(3055),d=i(8938),_=i(8158),u=i(6760);t.MAX_BUFFER_SIZE=4294967295;class f extends r.Disposable{constructor(e,t,i,n){super(),this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this._logService=n,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=a.DEFAULT_ATTR_DATA.clone(),this.savedCharset=u.DEFAULT_CHARSET,this.savedCharsets=[],this.savedGlevel=0,this.savedOriginMode=!1,this.savedWraparoundMode=!0,this.markers=[],this._nullCell=c.CellData.fromCharData([0,d.NULL_CELL_CHAR,d.NULL_CELL_WIDTH,d.NULL_CELL_CODE]),this._whitespaceCell=c.CellData.fromCharData([0,d.WHITESPACE_CELL_CHAR,d.WHITESPACE_CELL_WIDTH,d.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new o.IdleTaskQueue(this._logService),this._register((0,r.toDisposable)(()=>this._memoryCleanupQueue.clear())),this._register((0,r.toDisposable)(()=>this.clearAllMarkers())),this._stringCache=this._register(new h.BufferLineStringCache)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new a.BufferLine(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){e??=a.DEFAULT_ATTR_DATA;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(a.DEFAULT_ATTR_DATA);this._stringCache.clear();let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+o+1?(this.ybase--,o++,this.ydisp>0&&this.ydisp--):this.lines.push(new a.BufferLine(this._stringCache,e,i,!1)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),o&&(this.y+=o),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t0){const e=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,e)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=this._optionsService.rawOptions.reflowCursorLine,s=(0,l.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(a.DEFAULT_ATTR_DATA),i);if(s.length>0){const i=(0,l.reflowLargerCreateNewLayout)(this.lines,s);(0,l.reflowLargerApplyNewLayout)(this.lines,i.layout),this._reflowLargerAdjustViewport(e,t,i.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(a.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);if(!i){const e=this.ybase+this.y;if(e>=n&&e0&&(r.push({start:n+c.length+o,newLines:p}),o+=p.length),c.push(...p);let v=_.length-1,g=_[v];0===g&&(v--,g=_[v]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[v])break;if(c[v].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(v--,g=_[v]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,l.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;l--)if(a&&a.start>s+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(l--,a.newLines[e]);l++,e.push({index:s+1,amount:a.newLines.length}),h+=a.newLines.length,a=r[++n]}else this.lines.set(l,t[s--]);let l=0;for(let t=e.length-1;t>=0;t--)e[t].index+=l,this.lines.onInsertEmitter.fire(e[t]),l+=e[t].amount;const c=Math.max(0,i+o-this.lines.maxLength);c>0&&this.lines.onTrimEmitter.fire(c)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(e=>{t.line>=e.index&&(t.line+=e.amount)})),t.register(this.lines.onDelete(e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}t.Buffer=f},6107(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(5451),r=i(3055),o=i(8938),n=i(726),a=i(4220);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let h=0;const l=new r.CellData,c=new a.StringBuilder;class d{constructor(e,t,i,s=!1){this._stringCache=e,this.isWrapped=s,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*t);const n=i??r.CellData.fromCharData([0,o.NULL_CELL_CHAR,o.NULL_CELL_WIDTH,o.NULL_CELL_CODE]);for(let e=0;e>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._invalidateStringCache(),this._data[3*e+1]=t[o.CHAR_DATA_ATTR_INDEX],t[o.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[o.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[o.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[o.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,n.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,i){return h=3*e,i.content=this._data[h+0],i.fg=this._data[h+1],i.bg=this._data[h+2],2097152&i.content?i.combinedData=this._combined[e]:i.combinedData="",268435456&i.bg?i.extended=this._extendedAttrs[e]:i.extended=t.DEFAULT_ATTR_DATA.extended.clone(),i}setCell(e,t){this._invalidateStringCache(),2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodepoint(e,t,i,s){this._invalidateStringCache(),268435456&s.bg&&(this._extendedAttrs[e]=s.extended),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s.fg,this._data[3*e+2]=s.bg}addCodepointToCell(e,t,i){this._invalidateStringCache();let s=this._data[3*e+0];2097152&s?this._combined[e]+=(0,n.stringFromCodePoint)(t):2097151&s?(this._combined[e]=(0,n.stringFromCodePoint)(2097151&s)+(0,n.stringFromCodePoint)(t),s&=-2097152,s|=2097152):s=t|1<<22,i&&(s&=-12582913,s|=i<<22),this._data[3*e+0]=s}insertCells(e,t,i){if(this._invalidateStringCache(),(e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodepoint(e-1,0,1,i),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,l));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){this._invalidateStringCache();const o=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=o[3*(t+r)+e];this._copyCellMapsFrom(e,t+r,i+r)}else for(let r=0;r>22||1}s&&s.push(t);const h=c.toString();if(c.reset(),r){const t=this._getStringCacheEntry(!0);t.value=h,t.isTrimmed=!!e}return h}_getStringCacheEntry(e){const t=this._stringCacheEntryRef?.deref();if(t&&t.generation===this._stringCache.generation)return t;if(!e)return;const i=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(i),i}_invalidateStringCache(){const e=this._getStringCacheEntry(!1);e&&(e.value=void 0,e.isTrimmed=!1)}_copyCellMapsFrom(e,t,i){const s=3*t;2097152&e._data[s+0]&&(this._combined[i]=e._combined[t]),268435456&e._data[s+2]&&(this._extendedAttrs[i]=e._extendedAttrs[t])}_copySparseMapsFrom(e){this._combined={},this._extendedAttrs={};for(let t=0;tthis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){const e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(const e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),this._clearTimeout.value||this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=(0,s.disposableTimeout)(()=>{const e=Date.now()-this._lastAccessTimestamp;e>=15e3?this.clear():this._scheduleClearTimeout(15e3-e)},e)}}t.BufferLineStringCache=o},9384(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},732(e,t){function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.reflowLargerGetLinesToRemove=function(e,t,s,r,o,n){const a=[];for(let h=0;h=h&&r0&&(e>_||0===d[e].getTrimmedLength());e--)v++;v>0&&(a.push(h+d.length-v),a.push(v)),h+=d.length-1}return a},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],o=0;for(let n=0;nl&&(n-=l,a++);const c=2===e[a].getWidth(n-1);c&&n--;const d=c?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},4097(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(4812),r=i(1073),o=i(8636);class n extends s.Disposable{constructor(e,t,i){super(),this._optionsService=e,this._bufferService=t,this._logService=i,this._normalBuffer=this._register(new s.MutableDisposable),this._altBuffer=this._register(new s.MutableDisposable),this._onBufferActivate=this._register(new o.Emitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new r.Buffer(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new r.Buffer(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=n},3055(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(726),r=i(8938),o=i(5451);class n extends o.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new o.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new n;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor())return!1;if(this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor())return!1;if(this.isInverse()!==e.isInverse())return!1;if(this.isBold()!==e.isBold())return!1;if(this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;const t=this.isUnderlineColorDefault(),i=e.isUnderlineColorDefault();if(!t||!i){if(t!==i)return!1;if(this.getUnderlineColor()!==e.getUnderlineColor())return!1;if(this.getUnderlineColorMode()!==e.getUnderlineColorMode())return!1}}return this.isOverline()===e.isOverline()&&this.isBlink()===e.isBlink()&&this.isInvisible()===e.isInvisible()&&this.isItalic()===e.isItalic()&&this.isDim()===e.isDim()&&this.isStrikethrough()===e.isStrikethrough()}}t.CellData=n},8938(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=t.DEFAULT_COLOR<<9|256,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},8158(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(4812),r=i(8636);class o{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=o._nextId++,this._onDispose=this.register(new r.Emitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,s.dispose)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=o,o._nextId=1},6760(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},706(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=function(e,t,s,r){const o={type:0,cancel:!1,key:void 0},n=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?"OA":"":"UIKeyInputLeftArrow"===e.key?o.key=t?"OD":"":"UIKeyInputRightArrow"===e.key?o.key=t?"OC":"":"UIKeyInputDownArrow"===e.key&&(o.key=t?"OB":"");break;case 8:o.key=e.ctrlKey?"\b":"",e.altKey&&(o.key=""+o.key);break;case 9:if(e.shiftKey){o.key="";break}o.key="\t",o.cancel=!0;break;case 13:"c"===e.key&&e.ctrlKey?o.key="":o.key=e.altKey?"\r":"\r",o.cancel=!0;break;case 27:o.key="",e.altKey&&(o.key=""),o.cancel=!0;break;case 37:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"D":t?"OD":"";break;case 39:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"C":t?"OC":"";break;case 38:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"A":t?"OA":"";break;case 40:if(e.metaKey)break;o.key=n?"[1;"+(n+1)+"B":t?"OB":"";break;case 45:e.shiftKey||e.ctrlKey||(o.key="[2~");break;case 46:o.key=n?"[3;"+(n+1)+"~":"[3~";break;case 36:o.key=n?"[1;"+(n+1)+"H":t?"OH":"";break;case 35:o.key=n?"[1;"+(n+1)+"F":t?"OF":"";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key="[5;"+(n+1)+"~":o.key="[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key="[6;"+(n+1)+"~":o.key="[6~";break;case 112:o.key=n?"[1;"+(n+1)+"P":"OP";break;case 113:o.key=n?"[1;"+(n+1)+"Q":"OQ";break;case 114:o.key=n?"[1;"+(n+1)+"R":"OR";break;case 115:o.key=n?"[1;"+(n+1)+"S":"OS";break;case 116:o.key=n?"[15;"+(n+1)+"~":"[15~";break;case 117:o.key=n?"[17;"+(n+1)+"~":"[17~";break;case 118:o.key=n?"[18;"+(n+1)+"~":"[18~";break;case 119:o.key=n?"[19;"+(n+1)+"~":"[19~";break;case 120:o.key=n?"[20;"+(n+1)+"~":"[20~";break;case 121:o.key=n?"[21;"+(n+1)+"~":"[21~";break;case 122:o.key=n?"[23;"+(n+1)+"~":"[23~";break;case 123:o.key=n?"[24;"+(n+1)+"~":"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(s&&!r||!e.altKey||e.metaKey)if(!s||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey){if(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length)o.key=e.key;else if(e.key&&e.ctrlKey&&e.shiftKey)switch(e.code){case"Minus":o.key="";break;case"Digit2":o.key="\0";break;case"Digit6":o.key=""}}else 65===e.keyCode&&(o.type=1);else{const t=i[e.keyCode],s=t?.[e.shiftKey?1:0];if(s)o.key=""+s;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=""+i}else if(32===e.keyCode)o.key=""+(e.ctrlKey?"\0":" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=""+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key="\0":e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key="":"/"===e.key?o.key="":219===e.keyCode?o.key="":220===e.keyCode?o.key="":221===e.keyCode&&(o.key="")}return o};const i={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']}},7241(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.KittyKeyboard=void 0,t.KittyKeyboard=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440},this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24},this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"},this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(e){if(e.code.startsWith("Numpad")){const t=e.code.slice(6);if(t>="0"&&t<="9")return 57399+parseInt(t,10);switch(t){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(e){switch(e.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(e){let t=0;return e.shiftKey&&(t|=1),e.altKey&&(t|=2),e.ctrlKey&&(t|=4),e.metaKey&&(t|=8),t>0?t+1:0}_getKeyCode(e,t){const i=this._getNumpadKeyCode(e);if(void 0!==i)return i;const s=this._getModifierKeyCode(e);if(void 0!==s)return s;const r=this._functionalKeyCodes[e.key];if(void 0!==r)return r;if((e.shiftKey||t&&e.altKey)&&e.code){if(e.code.startsWith("Digit")&&6===e.code.length){const t=e.code.charAt(5);if(t>="0"&&t<="9")return t.charCodeAt(0)}if(e.code.startsWith("Key")&&4===e.code.length)return e.code.charAt(3).toLowerCase().charCodeAt(0)}if(1===e.key.length){const t=e.key.codePointAt(0);return t>=65&&t<=90?t+32:t}}_isModifierKey(e){return"Shift"===e.key||"Control"===e.key||"Alt"===e.key||"Meta"===e.key}_isLockKey(e){return"CapsLock"===e.key||"NumLock"===e.key||"ScrollLock"===e.key}_buildCsiLetterSequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"["+e}_buildSs3Sequence(e,t,i,s){const r=s&&1!==i;if(t>0||r){let s="[1;"+(t>0?t:"1");return r&&(s+=":"+i),s+=e,s}return"O"+e}_buildCsiTildeSequence(e,t,i,s){const r=s&&1!==i;let o="["+e;return(t>0||r)&&(o+=";"+(t>0?t:"1"),r&&(o+=":"+i)),o+="~",o}_buildCsiUSequence(e,t,i,s,r,o,n){const a=!!(2&r);let h,l="["+t;4&r&&e.shiftKey&&1===e.key.length&&!o&&!n&&(h=e.key.codePointAt(0),l+=":"+h);const c=16&r&&3!==s&&1===e.key.length&&!o&&!n&&!e.ctrlKey?e.key.codePointAt(0):void 0,d=a&&1!==s&&(3===s||void 0===c);return(i>0||d||void 0!==c)&&(l+=";",i>0?l+=i:d&&(l+="1"),d&&(l+=":"+s)),void 0!==c&&(l+=";"+c),l+="u",l}evaluate(e,t,i=1,s=!1){const r={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(e),n=this._isModifierKey(e),a=!!(2&t);if(!a&&3===i)return r;if(n&&!(8&t))return r;if(this._isLockKey(e)&&!(8&t))return r;const h=this._csiLetterKeys[e.key];if(h)return r.key=this._buildCsiLetterSequence(h,o,i,a),r.cancel=!0,r;const l=this._ss3FunctionKeys[e.key];if(l)return r.key=this._buildSs3Sequence(l,o,i,a),r.cancel=!0,r;const c=this._csiTildeKeys[e.key];if(void 0!==c)return r.key=this._buildCsiTildeSequence(c,o,i,a),r.cancel=!0,r;const d=this._getKeyCode(e,s);if(void 0===d)return r;const _=13===d||9===d||127===d;if(_&&3===i&&!(8&t))return r;const u=void 0!==this._functionalKeyCodes[e.key]||void 0!==this._getNumpadKeyCode(e);if(8&t||a&&3===i||(1&t||a)&&(u&&!_||o>0&&1!==e.key.length||o-1>1))r.key=this._buildCsiUSequence(e,d,o,i,t,u,n),r.cancel=!0;else{const t=13===d?"\r":9===d?"\t":127===d?"":void 0;t?r.key=t:1!==e.key.length||e.ctrlKey||e.altKey||e.metaKey||(r.key=e.key)}return r}static shouldUseProtocol(e){return e>0}}},726(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let o=r;o=i)return this._interim=r,s;const n=e.charCodeAt(o);56320<=n&&n<=57343?t[s++]=1024*(r-55296)+n-56320+65536:(t[s++]=r,t[s++]=n);continue}65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,o,n,a,h=0,l=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let o,n=0;for(;(o=this.interim[++n])&&n<4;)r<<=6,r|=63&o;const a=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,c=a-n;for(;l=i)return 0;if(o=e[l++],128!=(192&o)){l--,s=!0;break}this.interim[n++]=o,r<<=6,r|=63&o}s||(2===a?r<128?l--:t[h++]=r:3===a?r<2048||r>=55296&&r<=57343||65279===r||(t[h++]=r):r<65536||r>1114111||(t[h++]=r)),this.interim.fill(0)}const c=i-4;let d=l;for(;d=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(a=(31&s)<<6|63&r,a<128){d--;continue}t[h++]=a}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(a=(15&s)<<12|(63&r)<<6|63&o,a<2048||a>=55296&&a<=57343||65279===a)continue;t[h++]=a}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,h;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,h;if(o=e[d++],128!=(192&o)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=o,h;if(n=e[d++],128!=(192&n)){d--;continue}if(a=(7&s)<<18|(63&r)<<12|(63&o)<<6|63&n,a<65536||a>1114111)continue;t[h++]=a}}return h}}},7428(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const s=i(6415),r=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],o=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let n;t.UnicodeV6=class{constructor(){if(this.version="6",!n){n=new Uint8Array(65536),n.fill(1),n[0]=0,n.fill(0,1,32),n.fill(0,127,160),n.fill(2,4352,4448),n[9001]=2,n[9002]=2,n.fill(2,11904,42192),n[12351]=1,n.fill(2,44032,55204),n.fill(2,63744,64256),n.fill(2,65040,65050),n.fill(2,65072,65136),n.fill(2,65280,65377),n.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let i=this.wcwidth(e),r=0===i&&0!==t;if(r){const e=s.UnicodeService.extractWidth(t);0===e?r=!1:e>i&&(i=e)}return s.UnicodeService.createPropertyValue(0,i,r)}}},9249(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Win32InputMode=void 0,t.Win32InputMode=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226},this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40},this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]),this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(e){const t=this._codeToVk[e.code];return void 0!==t?t:e.keyCode||0}_getScanCode(e){return this._codeToScancode[e.code]||0}_getUnicodeChar(e){if(e.ctrlKey&&!e.altKey&&!e.metaKey){if("Enter"===e.key)return 10;if("Backspace"===e.key)return 127}const t=this._keyToControlChar[e.key];if(void 0!==t)return t;if(1===e.key.length){const t=e.key.codePointAt(0)||0;if(e.ctrlKey&&!e.altKey&&!e.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(e){let t=0;return e.shiftKey&&(t|=16),e.ctrlKey&&("ControlRight"===e.code?t|=4:t|=8),e.altKey&&("AltRight"===e.code?t|=1:t|=2),this._enhancedKeyCodes.has(e.code)&&(t|=256),t}evaluateKeyboardEvent(e,t){return{type:0,cancel:!0,key:`[${this._getVirtualKeyCode(e)};${this._getScanCode(e)};${this._getUnicodeChar(e)};${t?1:0};${this._getControlKeyState(e)};1_`}}}},3562(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(3132),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._innerWriteTimer=this._register(new s.TimeoutTimer),this._onWriteParsed=this._register(new o.Emitter),this.onWriteParsed=this._onWriteParsed.event,this._register((0,r.toDisposable)(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed)return;if(this._isSyncWriting)return;let e;this._isSyncWriting=!0;let t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);const i=this._callbacks.shift();i&&i()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;const i=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>{this._store.isDisposed||(performance.now()-i>=12?this._scheduleInnerWrite(0,e):this._innerWrite(i,e))};return void s.catch(e=>(queueMicrotask(()=>{throw e}),Promise.resolve(!1))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},8693(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(t.startsWith("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(t.startsWith("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,o]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(o,t)}`};const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}},2607(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.ApcHandler=t.ApcParser=void 0;const s=i(726),r=i(4220),o=[];t.ApcParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._ident=0}start(e){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._ident,"START")}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}end(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"END",e);this._active=o,this._ident=0}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.ApcHandler=n,n._payloadLimit=1e7},9823(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(726),r=i(7262),o=i(4220),n=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=n,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=n,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=n,this._ident=0}};const a=new r.Params;a.addParam(0);class h{constructor(e){this._handler=e,this._data=new o.LimitedStringBuilder(h._payloadLimit),this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString(),this._params),t instanceof Promise))return t.then(e=>(this._params=a,this._data.reset(),this._hitLimit=!1,e));return this._params=a,this._data.reset(),this._hitLimit=!1,t}}t.DcsHandler=h,h._payloadLimit=1e7},6717(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(4812),r=i(7262),o=i(1346),n=i(9823),a=i(2607);class h{constructor(e){this.table=new Uint16Array(e)}setDefault(e,t){this.table.fill(e<<8|t)}add(e,t,i,s){this.table[t<<8|e]=i<<8|s}addMany(e,t,i,s){for(let r=0;rt),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const o=i(0,17);e.setDefault(1,0),e.addMany(s,0,2,0);for(const t of o)e.addMany([24,26,153,154],t,3,0),e.addMany(i(128,144),t,3,0),e.addMany(i(144,152),t,3,0),e.add(156,t,0,0),e.add(27,t,11,1),e.add(157,t,4,8),e.addMany([152,158],t,0,7),e.add(159,t,11,14),e.add(155,t,11,3),e.add(144,t,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(95,1,11,14),e.addMany(r,14,0,14),e.add(127,14,0,14),e.addMany(i(32,48),14,9,15),e.addMany(i(48,127),14,15,16),e.addMany(i(48,127),15,15,16),e.addMany(r,15,0,15),e.addMany(i(32,48),15,9,15),e.add(127,15,0,15),e.addMany(s,16,16,16),e.addMany(r,16,0,16),e.addMany(i(8,14),16,16,16),e.add(127,16,0,16),e.addMany([27,156,24,26],16,17,0),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(l,0,2,0),e.add(l,8,5,8),e.add(l,6,0,6),e.add(l,11,0,11),e.add(l,13,13,13),e.add(l,16,16,16),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register((0,s.toDisposable)(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new o.OscParser),this._dcsParser=this._register(new n.DcsParser),this._apcParser=this._register(new a.ApcParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i<60||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);this._escHandlers[i]??=[];const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){const i=e.charCodeAt(0);this._executeHandlers[i]=t,i<24&&(this._executeHandlersArr[i]=t)}clearExecuteHandler(e){const t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);this._csiHandlers[i]??=[];const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r,o,n=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,n=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let r=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](this._params),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 4:if(!1===i&&r>-1)for(;r>=0&&(o=t[r](),!0!==o);r--)if(o instanceof Promise)return this._parseStack.handlerPos=r,o;this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],o=this._oscParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],o=this._apcParser.end(24!==s&&26!==s,i),o)return o;27===s&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0}this._parseStack.state=0,n=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=255&this._parseStack.transition}for(let i=n;i=60&&n<=63&&(this._collect=n,s++);let a=!1;for(;s=48&&n<=57)this._params.addDigit(n-48);else if(59===n)this._params.addParam(0);else{if(58!==n){if(n>=64&&n<=126){const e=this._csiHandlers[this._collect<<8|n];let t=e?e.length-1:-1;for(;t>=0&&(o=e[t](this._params),!0!==o);t--)if(o instanceof Promise)return r=1792,this._preserveStack(3,e,t,r,s),o;t<0&&this._csiHandlerFb(this._collect<<8|n,this._params),this.precedingJoinState=0,i=s,this.currentState=0,a=!0;break}break}this._params.addSubParam(-1)}a||(i=s-1,this.currentState=4);continue}switch(r=this._transitions.table[this.currentState<<8|(s>8){case 2:let n=i;const a=t-4;for(;n=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l)&&e[++n]>=32&&(e[n]<=126||e[n]>=l););if(n>=a)for(;n=32&&(e[n]<=126||e[n]>=l);)n++;this._printHandler(e,i,n),i=n-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:i,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:const h=this._csiHandlers[this._collect<<8|s];let c=h?h.length-1:-1;for(;c>=0&&(o=h[c](this._params),!0!==o);c--)if(o instanceof Promise)return this._preserveStack(3,h,c,r,i),o;c<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do{switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}}while(++i47&&s<60);i--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:const d=this._escHandlers[this._collect<<8|s];let _=d?d.length-1:-1;for(;_>=0&&(o=d[_](),!0!==o);_--)if(o instanceof Promise)return this._preserveStack(4,d,_,r,i),o;_<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let r=i+1;;++r)if(r>=t||24===(s=e[r])||26===s||27===s||s>127&&s=t||(s=e[r])<32||s>127&&s=32&&e[s]<127||e[s]>=8&&e[s]<14||e[s]>=l))){this._apcParser.put(e,i,s),i=s-1;break}break;case 17:if(o=this._apcParser.end(24!==s&&26!==s),o)return this._preserveStack(7,[],0,r,i),o;27===s&&(r|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0}this.currentState=255&r}}}t.EscapeSequenceParser=c},1346(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(726),r=i(4220),o=[];t.OscParser=class{constructor(){this._state=0,this._active=o,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]??=[];const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=o,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,s.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=o,this._id=-1,this._state=0}}};class n{constructor(e){this._handler=e,this._data=new r.LimitedStringBuilder(n._payloadLimit),this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(e,t,i){this._hitLimit||this._data.append((0,s.utf32ToString)(e,t,i))&&(this._hitLimit=!0)}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data.toString()),t instanceof Promise))return t.then(e=>(this._data.reset(),this._hitLimit=!1,e));return this._data.reset(),this._hitLimit=!1,t}}t.OscHandler=n,n._payloadLimit=1e7},7262(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;class i{static fromArray(e){const t=new i;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new i(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>2147483647?2147483647:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=e>2147483647?2147483647:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const i=this._digitIsSub?this._subParams:this.params,s=i[t-1];i[t-1]=~s?Math.min(10*s+e,2147483647):e}}t.Params=i},3027(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},5101(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(3235),r=i(4812),o=i(8636);class n extends r.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new o.Emitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=n},6097(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,e=>t(e.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(e,i)=>t(e,i.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}registerApcHandler(e,t){return this._core.registerApcHandler(e,t)}}},4335(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},9640(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=void 0;const o=i(4812),n=i(4097),a=i(6501),h=i(8636);let l=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e,t){super(),this.isUserScrolling=!1,this._onResize=this._register(new h.Emitter),this.onResize=this._onResize.event,this._onScroll=this._register(new h.Emitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new n.BufferSet(e,this,t)),this._register(this.buffers.onBufferActivate(e=>{this._onScroll.fire(e.activeBuffer.ydisp)}))}resize(e,t){const i=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:i,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,o=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;o===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(o+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=o-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(o,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t){const i=this.buffer;if(e<0){if(0===i.ydisp)return;this.isUserScrolling=!0}else e+i.ydisp>=i.ybase&&(this.isUserScrolling=!1);const s=i.ydisp;i.ydisp=Math.max(Math.min(i.ydisp+e,i.ybase),0),s!==i.ydisp&&(t||this._onScroll.fire(i.ydisp))}};t.BufferService=l,t.BufferService=l=s([r(0,a.IOptionsService),r(1,a.ILogService)],l)},5746(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},4071(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const o=i(4812),n=i(6501),a=i(8636),h=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0});let c=class extends o.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorHidden=!1,this._onData=this._register(new a.Emitter),this.onData=this._onData.event,this._onUserInput=this._register(new a.Emitter),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new a.Emitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new a.Emitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.isCursorInitialized=i.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}reset(){this.modes=structuredClone(h),this.decPrivateModes=structuredClone(l),this.kittyKeyboard={flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(e=>e.charCodeAt(0))),this._onBinary.fire(e))}};t.CoreService=c,t.CoreService=c=s([r(0,n.IBufferService),r(1,n.ILogService),r(2,n.IOptionsService)],c)},4720(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationLineCache=t.DecorationService=void 0;const o=i(3132),n=i(4103),a=i(4812),h=i(6501),l=i(3087),c=i(8636);let d=0,_=0,u=class extends a.Disposable{get decorations(){return this._decorations.values()}constructor(e,t){super(),this._logService=e,this._bufferService=t,this._lineCache=this._register(new f),this._onDecorationRegistered=this._register(new c.Emitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new c.Emitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this._decorations=new l.SortedList(e=>e?.marker.line,this._logService),this._register((0,a.toDisposable)(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}registerDecoration(e){if(e.marker.isDisposed)return;const t=new p(e);if(t){const e=t.marker.onDispose(()=>t.dispose()),i=t.onDispose(()=>{i.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),e.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,i){const s=this._lineCache.getDecorationsOnLine(t);if(s)for(const t of s)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&(yield t)}forEachDecorationAtCell(e,t,i,s){const r=this._lineCache.getDecorationsOnLine(t);if(r)for(const t of r)d=t.options.x??0,_=d+(t.options.width??1),e>=d&&e<_&&(!i||(t.options.layer??"bottom")===i)&&s(t)}};t.DecorationService=u,t.DecorationService=u=s([r(0,h.ILogService),r(1,h.IBufferService)],u);class f extends a.Disposable{constructor(){super(...arguments),this._decorationsByLine=new Map,this._decorations=new Set,this._bufferLineListeners=this._register(new a.MutableDisposable),this._lineIndexSyncTimer=this._register(new o.MicrotaskTimer),this._lineIndexSyncCallbacks=[]}clear(){this._lineIndexSyncCallbacks.length=0,this._lineIndexSyncTimer.cancel(),this._decorationsByLine.clear(),this._decorations.clear()}add(e){this._decorations.add(e),this._addToLineBuckets(e)}remove(e){this._decorations.delete(e),this._removeFromLineBuckets(e)}getDecorationsOnLine(e){return this._decorationsByLine.get(e)}attachToBufferLines(e){const t=new a.DisposableStore;this._bufferLineListeners.value=t,t.add(e.onTrim(e=>this._handleBufferLinesTrim(e))),t.add(e.onInsert(e=>this._handleBufferLinesInsert(e))),t.add(e.onDelete(e=>this._handleBufferLinesDelete(e)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){const t=e.marker.line;if(t<0)return;e._indexedStartLine=t;const i=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{const e=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(const t of e)t()})}_handleBufferLinesTrim(e){if(e<=0)return;const t=new Map;for(const[i,s]of this._decorationsByLine){const r=i-e;r<0||this._mergeLineBucket(t,r,s)}this._decorationsByLine.clear();for(const[e,i]of t)this._decorationsByLine.set(e,i);for(const t of this._decorations)t.marker.isDisposed||(t._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,i){const s=e.get(t);if(s)for(let e=0,t=i.length;et&&(s.push(e),this._removeFromLineBuckets(e))}const r=new Map;for(const[e,s]of this._decorationsByLine){const o=e>=t?e+i:e;this._mergeLineBucket(r,o,s)}this._decorationsByLine.clear();for(const[e,t]of r)this._decorationsByLine.set(e,t);for(const e of this._decorations)e.marker.isDisposed||e._indexedStartLine>=t&&(e._indexedStartLine=e.marker.line);for(const e of s)this._addToLineBuckets(e)}_applyBufferLinesDelete(e){const t=e.index+e.amount,i=new Map;for(const[s,r]of this._decorationsByLine){if(s>=e.index&&s=t?s-e.amount:s;this._mergeLineBucket(i,o,r)}this._decorationsByLine.clear();for(const[e,t]of i)this._decorationsByLine.set(e,t);const s=[];for(const i of this._decorations){if(i.marker.isDisposed)continue;const r=i._indexedStartLine,o=this._getDecorationHeight(i);r>=t?i._indexedStartLine=i.marker.line:rt&&s.push(i)}for(const e of s)this._reindexDecoration(e)}}t.DecorationLineCache=f;class p extends a.DisposableStore{get backgroundColorRGB(){return null===this._cachedBg&&(this.options.backgroundColor?this._cachedBg=n.css.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return null===this._cachedFg&&(this.options.foregroundColor?this._cachedFg=n.css.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}constructor(e){super(),this.options=e,this.onRenderEmitter=this.add(new c.Emitter),this.onRender=this.onRenderEmitter.event,this._onDispose=this.add(new c.Emitter),this.onDispose=this._onDispose.event,this._cachedBg=null,this._cachedFg=null,this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}dispose(){this._onDispose.fire(),super.dispose()}}},6025(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(6501),r=i(6201);class o{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=o,t.InstantiationService=class{constructor(){this._services=new o,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort((e,t)=>e.index-t.index),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);s.push(i)}const o=i.length>0?i[0].index:t.length;if(t.length!==o)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${o+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7276(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.LogService=void 0;const o=i(4812),n=i(6501),a={trace:n.LogLevelEnum.TRACE,debug:n.LogLevelEnum.DEBUG,info:n.LogLevelEnum.INFO,warn:n.LogLevelEnum.WARN,error:n.LogLevelEnum.ERROR,off:n.LogLevelEnum.OFF};let h=class extends o.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=n.LogLevelEnum.OFF,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;t!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function n(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const a=String.fromCharCode,h={DEFAULT:e=>{const t=[n(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${a(t[0])}${a(t[1])}${a(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${n(e,!0)};${e.x};${e.y}${t}`}};class l extends s.Disposable{constructor(){super(),this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._onProtocolChange=this._register(new r.Emitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(o))this.addProtocol(e,o[e]);for(const e of Object.keys(h))this.addEncoding(e,h[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return!this._customWheelEventHandler||!1!==this._customWheelEventHandler(e)}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return"DEFAULT"===this._activeEncoding}get isPixelEncoding(){return"SGR_PIXELS"===this._activeEncoding}}t.MouseStateService=l},56(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(4812),r=i(701),o=i(8636);t.DEFAULT_OPTIONS={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:r.isMac,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}};const n=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends s.Disposable{constructor(e){super(),this._onOptionChange=this._register(new o.Emitter),this.onOptionChange=this._onOptionChange.event;const i={...t.DEFAULT_OPTIONS};for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options={...i},this._setupOptions(),this._register((0,s.toDisposable)(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(i=>{i===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(i=>{-1!==e.indexOf(i)&&t()})}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=n.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"blinkIntervalDuration":if((i=Math.floor(i))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=i??{}}return i}}t.OptionsService=a},8811(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,o=arguments.length,n=o<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)n=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(n=(o<3?r(n):o>3?r(t,i,n):r(t,i))||n);return o>3&&n&&Object.defineProperty(t,i,n),n},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const o=i(6501);let n=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose(()=>this._removeMarkerFromLink(s,i)),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const o=t.addMarker(t.ybase+t.y),n={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(n,o)),this._entriesWithId.set(n.key,n),this._dataByLinkId.set(n.id,n),n.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every(e=>e.line!==t)){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose(()=>this._removeMarkerFromLink(i,e))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=n,t.OscLinkService=n=s([r(0,o.IBufferService)],n)},6201(e,t){Object.defineProperty(t,"__esModule",{value:!0}),t.serviceRegistry=void 0,t.getServiceDependencies=function(e){return e.di$dependencies||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const i=function(e,t,s){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,i){t.di$target===t?t.di$dependencies.push({id:e,index:i}):(t.di$dependencies=[{id:e,index:i}],t.di$target=t)}(i,e,s)};return i._id=e,t.serviceRegistry.set(e,i),i},t.serviceRegistry=new Map},6501(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.IMouseStateService=t.IBufferService=void 0;const s=i(6201);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.IMouseStateService=(0,s.createDecorator)("MouseStateService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},6415(e,t,i){Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8636);class r{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.Emitter,this.onChange=this._onChange.event}static extractShouldJoin(e){return!!(1&e)}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,i=!1){return(16777215&e)<<3|(3&t)<<1|(i?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e,this._active||(this.activeVersion=e.version)}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0,i=0;const s=e.length;for(let o=0;o=s)return t+this.wcwidth(n);const i=e.charCodeAt(o);56320<=i&&i<=57343?n=1024*(n-55296)+i-56320+65536:t+=this.wcwidth(i)}const a=this.charProperties(n,i);let h=r.extractWidth(a);r.extractShouldJoin(a)&&(h-=r.extractWidth(i)),t+=h,i=a}return t}charProperties(e,t){return this._activeProvider.charProperties(e,t)}}t.UnicodeService=r}},t={};return function i(s){var r=t[s];if(void 0!==r)return r.exports;var o=t[s]={exports:{}};return e[s].call(o.exports,o,o.exports,i),o.exports}(6081)})()); //# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/lib/xterm.mjs b/lib/xterm.mjs -index 9bc5087a0729e74e20b59eddf5d8259e056de338..9fa15d1e81c591d9e8cffa621d37a4ed54b80a4f 100644 +index 9bc5087a0729e74e20b59eddf5d8259e056de338..e09e996e12e197c60efa1178b2e228024225526a 100644 --- a/lib/xterm.mjs +++ b/lib/xterm.mjs @@ -17,11 +17,11 @@ var Ms=Object.defineProperty;var kn=Object.getOwnPropertyDescriptor;var Mn=(n,i)=>{for(var e in i)Ms(n,e,{get:i[e],enumerable:!0})};var y=(n,i,e,t)=>{for(var r=t>1?void 0:t?kn(i,e):i,s=n.length-1,o;s>=0;s--)(o=n[s])&&(r=(t?o(i,e,r):o(r))||r);return t&&r&&Ms(i,e,r),r},m=(n,i)=>(e,t)=>i(e,t,n);var Bs="Terminal input",Ut={get:()=>Bs,set:n=>Bs=n},Ps="Too much output to announce, navigate to rows manually to read",Ze={get:()=>Ps,set:n=>Ps=n};function Bn(n){return n.replace(/\r?\n/g,"\r")}function Pn(n,i){return i?`\x1B[200~${n.replace(/\x1b/g,"\u241B")}\x1B[201~`:n}function Os(n,i){n.clipboardData&&n.clipboardData.setData("text/plain",i.selectionText),n.preventDefault()}function Ns(n,i,e,t){if(n.stopPropagation(),n.clipboardData){let r=n.clipboardData.getData("text/plain");Nr(r,i,e,t)}}function Nr(n,i,e,t){n=Bn(n),n=Pn(n,e.decPrivateModes.bracketedPasteMode&&t.rawOptions.ignoreBracketedPasteMode!==!0),e.triggerDataEvent(n,!0),i.value=""}function Fr(n,i,e){let t=e.getBoundingClientRect(),r=n.clientX-t.left-10,s=n.clientY-t.top-10;i.style.width="20px",i.style.height="20px",i.style.left=`${r}px`,i.style.top=`${s}px`,i.style.zIndex="1000",i.focus()}function Hr(n,i,e,t,r){Fr(n,i,e),r&&t.rightClickSelect(n),i.value=t.selectionText,i.select()}function be(n){return n>65535?(n-=65536,String.fromCharCode((n>>10)+55296)+String.fromCharCode(n%1024+56320)):String.fromCharCode(n)}function ye(n,i=0,e=n.length){let t="";for(let r=i;r65535?(s-=65536,t+=String.fromCharCode((s>>10)+55296)+String.fromCharCode(s%1024+56320)):t+=String.fromCharCode(s)}return t}var mi=class{constructor(){this._interim=0}clear(){this._interim=0}decode(i,e){let t=i.length;if(!t)return 0;let r=0,s=0;if(this._interim){let o=i.charCodeAt(s++);56320<=o&&o<=57343?e[r++]=(this._interim-55296)*1024+o-56320+65536:(e[r++]=this._interim,e[r++]=o),this._interim=0}for(let o=s;o=t)return this._interim=a,r;let l=i.charCodeAt(o);56320<=l&&l<=57343?e[r++]=(a-55296)*1024+l-56320+65536:(e[r++]=a,e[r++]=l);continue}a!==65279&&(e[r++]=a)}return r}},bi=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(i,e){let t=i.length;if(!t)return 0;let r=0,s,o,a,l,h,d=0;if(this.interim[0]){let _=!1,p=this.interim[0];p&=(p&224)===192?31:(p&240)===224?15:7;let v=0,f;for(;(f=this.interim[++v])&&v<4;)p<<=6,p|=f&63;let S=(this.interim[0]&224)===192?2:(this.interim[0]&240)===224?3:4,I=S-v;for(;d=t)return 0;if(f=i[d++],(f&192)!==128){d--,_=!0;break}else this.interim[v++]=f,p<<=6,p|=f&63}_||(S===2?p<128?d--:e[r++]=p:S===3?p<2048||p>=55296&&p<=57343||p===65279||(e[r++]=p):p<65536||p>1114111||(e[r++]=p)),this.interim.fill(0)}let c=t-4,u=d;for(;u=t)return this.interim[0]=s,r;if(o=i[u++],(o&192)!==128){u--;continue}if(h=(s&31)<<6|o&63,h<128){u--;continue}e[r++]=h}else if((s&240)===224){if(u>=t)return this.interim[0]=s,r;if(o=i[u++],(o&192)!==128){u--;continue}if(u>=t)return this.interim[0]=s,this.interim[1]=o,r;if(a=i[u++],(a&192)!==128){u--;continue}if(h=(s&15)<<12|(o&63)<<6|a&63,h<2048||h>=55296&&h<=57343||h===65279)continue;e[r++]=h}else if((s&248)===240){if(u>=t)return this.interim[0]=s,r;if(o=i[u++],(o&192)!==128){u--;continue}if(u>=t)return this.interim[0]=s,this.interim[1]=o,r;if(a=i[u++],(a&192)!==128){u--;continue}if(u>=t)return this.interim[0]=s,this.interim[1]=o,this.interim[2]=a,r;if(l=i[u++],(l&192)!==128){u--;continue}if(h=(s&7)<<18|(o&63)<<12|(a&63)<<6|l&63,h<65536||h>1114111)continue;e[r++]=h}}return r}};var ue=class n{constructor(){this.fg=0;this.bg=0;this.extended=new ke}static toColorRGB(i){return[i>>>16&255,i>>>8&255,i&255]}static fromColorRGB(i){return(i[0]&255)<<16|(i[1]&255)<<8|i[2]&255}clone(){let i=new n;return i.fg=this.fg,i.bg=this.bg,i.extended=this.extended.clone(),i}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)===50331648}isBgRGB(){return(this.bg&50331648)===50331648}isFgPalette(){return(this.fg&50331648)===16777216||(this.fg&50331648)===33554432}isBgPalette(){return(this.bg&50331648)===16777216||(this.bg&50331648)===33554432}isFgDefault(){return(this.fg&50331648)===0}isBgDefault(){return(this.bg&50331648)===0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===16777216||(this.extended.underlineColor&50331648)===33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)===0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},ke=class n{constructor(i=0,e=0){this._ext=0;this._urlId=0;this._ext=i,this._urlId=e}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(i){this._ext=i}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(i){this._ext&=-469762049,this._ext|=i<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(i){this._ext&=-67108864,this._ext|=i&67108863}get urlId(){return this._urlId}set urlId(i){this._urlId=i}get underlineVariantOffset(){let i=(this._ext&3758096384)>>29;return i<0?i^4294967288:i}set underlineVariantOffset(i){this._ext&=536870911,this._ext|=i<<29&3758096384}clone(){return new n(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}};var F=class n extends ue{constructor(){super(...arguments);this.content=0;this.fg=0;this.bg=0;this.extended=new ke;this.combinedData=""}static fromCharData(e){let t=new n;return t.setFromCharData(e),t}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?be(this.content&2097151):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(e){this.fg=e[0],this.bg=0;let t=!1;if(e[1].length>2)t=!0;else if(e[1].length===2){let r=e[1].charCodeAt(0);if(55296<=r&&r<=56319){let s=e[1].charCodeAt(1);56320<=s&&s<=57343?this.content=(r-55296)*1024+s-56320+65536|e[2]<<22:t=!0}else t=!0}else this.content=e[1].charCodeAt(0)|e[2]<<22;t&&(this.combinedData=e[1],this.content=2097152|e[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}attributesEquals(e){if(this.getFgColorMode()!==e.getFgColorMode()||this.getFgColor()!==e.getFgColor()||this.getBgColorMode()!==e.getBgColorMode()||this.getBgColor()!==e.getBgColor()||this.isInverse()!==e.isInverse()||this.isBold()!==e.isBold()||this.isUnderline()!==e.isUnderline())return!1;if(this.isUnderline()){if(this.getUnderlineStyle()!==e.getUnderlineStyle())return!1;let t=this.isUnderlineColorDefault(),r=e.isUnderlineColorDefault();if(!(t&&r)&&(t!==r||this.getUnderlineColor()!==e.getUnderlineColor()||this.getUnderlineColorMode()!==e.getUnderlineColorMode()))return!1}return!(this.isOverline()!==e.isOverline()||this.isBlink()!==e.isBlink()||this.isInvisible()!==e.isInvisible()||this.isItalic()!==e.isItalic()||this.isDim()!==e.isDim()||this.isStrikethrough()!==e.isStrikethrough())}};var zr=new Map;function Hs(n){return n.di$dependencies||[]}function H(n){if(zr.has(n))return zr.get(n);let i=function(e,t,r){if(arguments.length!==3)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");Fn(i,e,r)};return i._id=n,zr.set(n,i),i}function Fn(n,i,e){i.di$target===i?i.di$dependencies.push({id:n,index:e}):(i.di$dependencies=[{id:n,index:e}],i.di$target=i)}var D=H("BufferService"),Me=H("MouseStateService"),Y=H("CoreService"),Ws=H("CharsetService"),Qe=H("InstantiationService");var fe=H("LogService"),R=H("OptionsService"),vi=H("OscLinkService"),Us=H("UnicodeService"),ge=H("DecorationService");var et=class{constructor(i,e,t){this._bufferService=i;this._optionsService=e;this._oscLinkService=t;this._workCell=new F}provideLinks(i,e){let t=this._bufferService.buffer.lines.get(i-1);if(!t){e(void 0);return}let r=[],s=this._optionsService.rawOptions.linkHandler,o=this._workCell,a=t.getTrimmedLength(),l=-1,h=-1,d=!1;for(let c=0;cs?s.activate(f,S,p):Hn(f,S),hover:(f,S)=>s?.hover?.(f,S,p),leave:(f,S)=>s?.leave?.(f,S,p)})}d=!1,o.hasExtendedAttrs()&&o.extended.urlId?(h=c,l=o.extended.urlId):(h=-1,l=-1)}}e(r)}_getRangeWithLineWrap(i,e,t,r){let s=i,o=e,a=i,l=t;for(;o===0&&this._bufferService.buffer.lines.get(s-1)?.isWrapped;){let d=this._bufferService.buffer.lines.get(s-2);if(!d)break;let c=d.getTrimmedLength();if(c===0||!this._hasUrlId(d,c-1,r))break;let u=c-1;for(;u>0&&this._hasUrlId(d,u-1,r);)u--;s--,o=u}for(;;){let h=this._bufferService.buffer.lines.get(a-1);if(!h)break;let d=h.getTrimmedLength();if(l!==d)break;let c=this._bufferService.buffer.lines.get(a);if(!c?.isWrapped)break;let u=c.getTrimmedLength();if(u===0||!this._hasUrlId(c,0,r))break;let _=1;for(;_{n(),e&&r.dispose()},i),r=E(()=>{clearTimeout(t)});return e?.add(r),r}var Ie=class{constructor(){this._token=-1;this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._token!==-1&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(i,e){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed TimeoutTimer");this.cancel(),this._token=setTimeout(()=>{this._token=-1,i()},e)}setIfNotSet(i,e){if(this._isDisposed)throw new Error("Calling setIfNotSet on a disposed TimeoutTimer");this._token===-1&&(this._token=setTimeout(()=>{this._token=-1,i()},e))}},Ci=class{constructor(){this._isScheduled=!1;this._isDisposed=!1}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._isScheduled=!1}set(i){if(this._isDisposed)throw new Error("Calling set on a disposed MicrotaskTimer");this._isScheduled||(this._isScheduled=!0,queueMicrotask(()=>{this._isScheduled&&(this._isScheduled=!1,i())}))}},Ei=class{constructor(){this._isDisposed=!1}cancel(){this._disposable?.dispose(),this._disposable=void 0}cancelAndSet(i,e,t=globalThis){if(this._isDisposed)throw new Error("Calling cancelAndSet on a disposed IntervalTimer");this.cancel();let r=t.setInterval(()=>{i()},e);this._disposable={dispose:()=>{t.clearInterval(r),this._disposable=void 0}}}dispose(){this.cancel(),this._isDisposed=!0}};function se(n){let i=n;if(i?.ownerDocument?.defaultView)return i.ownerDocument.defaultView;let e=n;return e?.view?e.view:window}var Gr=class{constructor(i,e,t,r){this._node=i,this._type=e,this._handler=t,this._options=r,i.addEventListener(e,t,r)}dispose(){!this._node||!this._handler||(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,this._handler=null)}};function C(n,i,e,t){return new Gr(n,i,e,t)}function Vr(n,i,e,t){return C(n,i,e,t)}var le={CLICK:"click",MOUSE_DOWN:"mousedown",MOUSE_OVER:"mouseover",MOUSE_LEAVE:"mouseleave",KEY_DOWN:"keydown",KEY_UP:"keyup",INPUT:"input",BLUR:"blur",FOCUS:"focus",CHANGE:"change",POINTER_DOWN:"pointerdown",POINTER_MOVE:"pointermove",POINTER_UP:"pointerup",MOUSE_WHEEL:"wheel",WHEEL:"wheel"};function $s(n){let i=n.getBoundingClientRect(),e=se(n);return{left:i.left+e.scrollX,top:i.top+e.scrollY,width:i.width,height:i.height}}var yi=class{constructor(i,e){this._runner=i;this.priority=e;this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(i){console.error(i)}}static sort(i,e){return e.priority-i.priority}},Vs=new Map;function qs(n){let i=Vs.get(n);return i||(i={next:[],current:[],animFrameRequested:!1,inAnimationFrameRunner:!1},Vs.set(n,i)),i}function Wn(n){let i=qs(n);for(i.animFrameRequested=!1,i.current=i.next,i.next=[],i.inAnimationFrameRunner=!0;i.current.length>0;)i.current.sort(yi.sort),i.current.shift().execute();i.inAnimationFrameRunner=!1}function tt(n,i,e=0){let t=qs(n),r=new yi(i,e);return t.next.push(r),t.animFrameRequested||(t.animFrameRequested=!0,n.requestAnimationFrame(()=>Wn(n))),r}var xi=class extends Ei{constructor(i){super(),this._defaultTarget=i?se(i):void 0}cancelAndSet(i,e,t){super.cancelAndSet(i,e,t??this._defaultTarget??window)}};var we=class{constructor(i){this.domNode=i;this._width="";this._height="";this._top="";this._left="";this._bottom="";this._right="";this._className="";this._position="";this._layerHint=!1;this._contain="none"}setWidth(i){let e=rt(i);this._width!==e&&(this._width=e,this.domNode.style.width=this._width)}setHeight(i){let e=rt(i);this._height!==e&&(this._height=e,this.domNode.style.height=this._height)}setTop(i){let e=rt(i);this._top!==e&&(this._top=e,this.domNode.style.top=this._top)}setLeft(i){let e=rt(i);this._left!==e&&(this._left=e,this.domNode.style.left=this._left)}setBottom(i){let e=rt(i);this._bottom!==e&&(this._bottom=e,this.domNode.style.bottom=this._bottom)}setRight(i){let e=rt(i);this._right!==e&&(this._right=e,this.domNode.style.right=this._right)}setClassName(i){this._className!==i&&(this._className=i,this.domNode.className=this._className)}toggleClassName(i,e){this.domNode.classList.toggle(i,e),this._className=this.domNode.className}setPosition(i){this._position!==i&&(this._position=i,this.domNode.style.position=this._position)}setLayerHinting(i){this._layerHint!==i&&(this._layerHint=i,i?this.domNode.style.transform="translate3d(0px, 0px, 0px)":this.domNode.style.transform="")}setContain(i){this._contain!==i&&(this._contain=i,this.domNode.style.contain=this._contain)}setAttribute(i,e){this.domNode.setAttribute(i,e)}};function rt(n){return typeof n=="number"?`${n}px`:n}var Ke={};Mn(Ke,{getSafariVersion:()=>Kn,getZoomFactor:()=>Xr,isChrome:()=>Kt,isChromeOS:()=>Yr,isFirefox:()=>nt,isLegacyEdge:()=>Un,isLinux:()=>zt,isMac:()=>ie,isNode:()=>$r,isSafari:()=>wi,isWindows:()=>Ue});var $r=!!(typeof process<"u"&&"title"in process&&(typeof navigator>"u"||navigator.userAgent.startsWith("Node.js/"))),st=$r?"node":navigator.userAgent,qr=$r?"node":navigator.platform,nt=st.includes("Firefox"),Kt=st.includes("Chrome"),Un=st.includes("Edge"),wi=/^((?!chrome|android).)*safari/i.test(st);function Xr(n){return 1}function Kn(){if(!wi)return 0;let n=st.match(/Version\/(\d+)/);return n===null||n.length<2?0:parseInt(n[1],10)}var ie=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(qr),Ue=["Windows","Win16","Win32","WinCE"].includes(qr),zt=qr.indexOf("Linux")>=0,Yr=/\bCrOS\b/.test(st);var Xs=new WeakMap;function zn(n){if(!n.parent||n.parent===n)return null;try{let i=n.location,e=n.parent.location;if(i.origin!=="null"&&e.origin!=="null"&&i.origin!==e.origin)return null}catch{return null}return n.parent}var jr=class{static _getSameOriginWindowChain(i){let e=Xs.get(i);if(!e){e=[],Xs.set(i,e);let t=i,r;do r=zn(t),r?e.push({window:new WeakRef(t),iframeElement:t.frameElement??null}):e.push({window:new WeakRef(t),iframeElement:null}),t=r;while(t)}return e.slice(0)}static getPositionOfChildWindowRelativeToAncestorWindow(i,e){if(!e||i===e)return{top:0,left:0};let t=0,r=0,s=this._getSameOriginWindowChain(i);for(let o of s){let a=o.window.deref();if(t+=a?.scrollY??0,r+=a?.scrollX??0,a===e||!o.iframeElement)break;let l=o.iframeElement.getBoundingClientRect();t+=l.top,r+=l.left}return{top:t,left:r}}},ot=class{constructor(i,e){this.timestamp=Date.now(),this.browserEvent=e,this.leftButton=e.button===0,this.middleButton=e.button===1,this.rightButton=e.button===2,this.buttons=e.buttons,this.target=e.target,this.detail=e.detail??1,e.type==="dblclick"&&(this.detail=2),this.ctrlKey=e.ctrlKey,this.shiftKey=e.shiftKey,this.altKey=e.altKey,this.metaKey=e.metaKey,typeof e.pageX=="number"?(this.posx=e.pageX,this.posy=e.pageY):(this.posx=e.clientX+this.target.ownerDocument.body.scrollLeft+this.target.ownerDocument.documentElement.scrollLeft,this.posy=e.clientY+this.target.ownerDocument.body.scrollTop+this.target.ownerDocument.documentElement.scrollTop);let t=jr.getPositionOfChildWindowRelativeToAncestorWindow(i,e.view);this.posx-=t.left,this.posy-=t.top}preventDefault(){this.browserEvent.preventDefault()}stopPropagation(){this.browserEvent.stopPropagation()}},Gt=class{constructor(i,e=0,t=0){this.browserEvent=i??null,this.target=i?i.target??i.targetNode??i.srcElement??null:null,this.deltaY=t,this.deltaX=e;let r=!1;if(Kt){let s=navigator.userAgent.match(/Chrome\/(\d+)/);r=(s?parseInt(s[1],10):123)<=122}if(i){let s=i,o=i,a=i.view?.devicePixelRatio??1;if(typeof s.wheelDeltaY<"u")r?this.deltaY=s.wheelDeltaY/(120*a):this.deltaY=s.wheelDeltaY/120;else if(typeof o.VERTICAL_AXIS<"u"&&o.axis===o.VERTICAL_AXIS)this.deltaY=-o.detail/3;else if(i.type==="wheel"){let l=i;l.deltaMode===l.DOM_DELTA_LINE?nt&&!ie?this.deltaY=-i.deltaY/3:this.deltaY=-i.deltaY:this.deltaY=-i.deltaY/40}if(typeof s.wheelDeltaX<"u")wi&&Ue?this.deltaX=-(s.wheelDeltaX/120):r?this.deltaX=s.wheelDeltaX/(120*a):this.deltaX=s.wheelDeltaX/120;else if(typeof o.HORIZONTAL_AXIS<"u"&&o.axis===o.HORIZONTAL_AXIS)this.deltaX=-i.detail/3;else if(i.type==="wheel"){let l=i;l.deltaMode===l.DOM_DELTA_LINE?nt&&!ie?this.deltaX=-i.deltaX/3:this.deltaX=-i.deltaX:this.deltaX=-i.deltaX/40}this.deltaY===0&&this.deltaX===0&&i.wheelDelta&&(r?this.deltaY=i.wheelDelta/(120*a):this.deltaY=i.wheelDelta/120)}}preventDefault(){this.browserEvent?.preventDefault()}stopPropagation(){this.browserEvent?.stopPropagation()}};var at=class{constructor(){this._hooks=new pe;this._pointerMoveCallback=null;this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(i){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;let e=this._onStopCallback;this._onStopCallback=null,i&&e&&e()}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(i,e,t,r,s){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=r,this._onStopCallback=s;let o=i;try{i.setPointerCapture(e),this._hooks.add(E(()=>{try{i.releasePointerCapture(e)}catch{}}))}catch{o=se(i)}this._hooks.add(C(o,le.POINTER_MOVE,a=>{if(a.buttons!==t){this.stopMonitoring(!0);return}a.preventDefault(),this._pointerMoveCallback(a)})),this._hooks.add(C(o,le.POINTER_UP,a=>this.stopMonitoring(!0)))}};var Ne=class extends g{_onclick(i,e){this._register(C(i,le.CLICK,t=>e(new ot(se(i),t))))}_onmouseover(i,e){this._register(C(i,le.MOUSE_OVER,t=>e(new ot(se(i),t))))}_onmouseleave(i,e){this._register(C(i,le.MOUSE_LEAVE,t=>e(new ot(se(i),t))))}};var Ti=class extends Ne{constructor(i){super(),this._handleActivate=i.handleActivate,this.bgDomNode=document.createElement("div"),this.bgDomNode.className="xterm-arrow-background",this.bgDomNode.style.position="absolute",this.bgDomNode.style.width=i.bgWidth+"px",this.bgDomNode.style.height=i.bgHeight+"px",typeof i.top<"u"&&(this.bgDomNode.style.top="0px"),typeof i.left<"u"&&(this.bgDomNode.style.left="0px"),typeof i.bottom<"u"&&(this.bgDomNode.style.bottom="0px"),typeof i.right<"u"&&(this.bgDomNode.style.right="0px"),this.domNode=document.createElement("div"),this.domNode.className=i.className,this.domNode.style.position="absolute";let e=Math.min(i.bgWidth,i.bgHeight);this.domNode.style.width=e+"px",this.domNode.style.height=e+"px",typeof i.top<"u"&&(this.domNode.style.top=i.top+"px"),typeof i.left<"u"&&(this.domNode.style.left=i.left+"px"),typeof i.bottom<"u"&&(this.domNode.style.bottom=i.bottom+"px"),typeof i.right<"u"&&(this.domNode.style.right=i.right+"px"),this._pointerMoveMonitor=this._register(new at),this._register(Vr(this.bgDomNode,le.POINTER_DOWN,t=>this._arrowPointerDown(t))),this._register(Vr(this.domNode,le.POINTER_DOWN,t=>this._arrowPointerDown(t))),this._pointerdownRepeatTimer=this._register(new xi),this._pointerdownScheduleRepeatTimer=this._register(new Ie)}_arrowPointerDown(i){if(!i.target||!(i.target instanceof Element))return;let e=()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._handleActivate(),1e3/24,se(i))};this._handleActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(e,200),this._pointerMoveMonitor.startMonitoring(i.target,i.pointerId,i.buttons,t=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),i.preventDefault()}};var b=class{constructor(){this._listeners=[];this._disposed=!1}get event(){return this._event?this._event:(this._event=(i,e,t)=>{if(this._disposed)return E(()=>{});let r={fn:i,thisArgs:e};this._listeners.push(r);let s=E(()=>{let o=this._listeners.indexOf(r);o!==-1&&this._listeners.splice(o,1)});return t&&(Array.isArray(t)?t.push(s):t.add(s)),s},this._event)}fire(i){if(!this._disposed)switch(this._listeners.length){case 0:return;case 1:{let{fn:e,thisArgs:t}=this._listeners[0];e.call(t,i);return}default:{let e=this._listeners.slice();for(let{fn:t,thisArgs:r}of e)t.call(r,i)}}}dispose(){this._disposed||(this._disposed=!0,this._listeners.length=0)}},j;(r=>{function n(s,o){return s(a=>o.fire(a))}r.forward=n;function i(s,o){return(a,l,h)=>s(d=>a.call(l,o(d)),void 0,h)}r.map=i;function e(...s){return(o,a,l)=>{let h=new pe;for(let d of s)h.add(d(c=>o.call(a,c)));return l&&(Array.isArray(l)?l.push(h):l.add(h)),h}}r.any=e;function t(s,o,a){return o(a),s(l=>o(l))}r.runAndSubscribe=t})(j||={});var Jr=class n{constructor(i,e,t,r,s,o,a){this._forceIntegerValues=i;this._scrollStateBrand=void 0;this._forceIntegerValues&&(e=e|0,t=t|0,r=r|0,s=s|0,o=o|0,a=a|0),this.rawScrollLeft=r,this.rawScrollTop=a,e<0&&(e=0),r+e>t&&(r=t-e),r<0&&(r=0),s<0&&(s=0),a+s>o&&(a=o-s),a<0&&(a=0),this.width=e,this.scrollWidth=t,this.scrollLeft=r,this.height=s,this.scrollHeight=o,this.scrollTop=a}equals(i){return this.rawScrollLeft===i.rawScrollLeft&&this.rawScrollTop===i.rawScrollTop&&this.width===i.width&&this.scrollWidth===i.scrollWidth&&this.scrollLeft===i.scrollLeft&&this.height===i.height&&this.scrollHeight===i.scrollHeight&&this.scrollTop===i.scrollTop}withScrollDimensions(i,e){return new n(this._forceIntegerValues,typeof i.width<"u"?i.width:this.width,typeof i.scrollWidth<"u"?i.scrollWidth:this.scrollWidth,e?this.rawScrollLeft:this.scrollLeft,typeof i.height<"u"?i.height:this.height,typeof i.scrollHeight<"u"?i.scrollHeight:this.scrollHeight,e?this.rawScrollTop:this.scrollTop)}withScrollPosition(i){return new n(this._forceIntegerValues,this.width,this.scrollWidth,typeof i.scrollLeft<"u"?i.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,typeof i.scrollTop<"u"?i.scrollTop:this.rawScrollTop)}createScrollEvent(i,e){let t=this.width!==i.width,r=this.scrollWidth!==i.scrollWidth,s=this.scrollLeft!==i.scrollLeft,o=this.height!==i.height,a=this.scrollHeight!==i.scrollHeight,l=this.scrollTop!==i.scrollTop;return{inSmoothScrolling:e,oldWidth:i.width,oldScrollWidth:i.scrollWidth,oldScrollLeft:i.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:i.height,oldScrollHeight:i.scrollHeight,oldScrollTop:i.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:t,scrollWidthChanged:r,scrollLeftChanged:s,heightChanged:o,scrollHeightChanged:a,scrollTopChanged:l}}},lt=class extends g{constructor(e){super();this._scrollableBrand=void 0;this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new Jr(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){let r=this._state.withScrollDimensions(e,t);this._setState(r,!!this._smoothScrolling),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){let t=this._state.withScrollPosition(e);this._smoothScrolling&&(this._smoothScrolling.dispose(),this._smoothScrolling=null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(this._smoothScrollDuration===0){this.setScrollPositionNow(e);return}if(this._smoothScrolling){e={scrollLeft:typeof e.scrollLeft>"u"?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:typeof e.scrollTop>"u"?this._smoothScrolling.to.scrollTop:e.scrollTop};let r=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===r.scrollLeft&&this._smoothScrolling.to.scrollTop===r.scrollTop)return;let s;t?s=new Vt(this._smoothScrolling.from,r,this._smoothScrolling.startTime,this._smoothScrolling.duration):s=Vt.start(this._state,r,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=s}else{let r=this._state.withScrollPosition(e);this._smoothScrolling=Vt.start(this._state,r,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}hasPendingScrollAnimation(){return!!this._smoothScrolling}_performSmoothScrolling(){if(!this._smoothScrolling)return;let e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);if(this._setState(t,!0),!!this._smoothScrolling){if(e.isDone){this._smoothScrolling.dispose(),this._smoothScrolling=null;return}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}}_setState(e,t){let r=this._state;r.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(r,t)))}},Di=class{constructor(i,e,t){this.scrollLeft=i,this.scrollTop=e,this.isDone=t}};function Zr(n,i){let e=i-n;return function(t){return n+e*$n(t)}}function Gn(n,i,e){return function(t){return t2.5*t){let s,o;return i{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(i){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(i?" xterm-fade":"")))}};var qn=140,ct=class extends Ne{constructor(i){super(),this._lazyRender=i.lazyRender,this._host=i.host,this._scrollable=i.scrollable,this._scrollByPage=i.scrollByPage,this._scrollbarState=i.scrollbarState,this._visibilityController=this._register(new Ri(i.visibility,"xterm-visible xterm-scrollbar "+i.extraScrollbarClassName,"xterm-invisible xterm-scrollbar "+i.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new at),this._shouldRender=!0,this.domNode=new we(document.createElement("div")),this.domNode.setAttribute("role","presentation"),this.domNode.setAttribute("aria-hidden","true"),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition("absolute"),this._register(C(this.domNode.domNode,le.POINTER_DOWN,e=>this._domNodePointerDown(e)))}_createArrow(i){let e=this._register(new Ti(i));return this.domNode.domNode.appendChild(e.bgDomNode),this.domNode.domNode.appendChild(e.domNode),e}_createSlider(i,e,t,r){this.slider=new we(document.createElement("div")),this.slider.setClassName("xterm-slider"),this.slider.setPosition("absolute"),this.slider.setTop(i),this.slider.setLeft(e),typeof t=="number"&&this.slider.setWidth(t),typeof r=="number"&&this.slider.setHeight(r),this.slider.setLayerHinting(!0),this.slider.setContain("strict"),this.domNode.domNode.appendChild(this.slider.domNode),this._register(C(this.slider.domNode,le.POINTER_DOWN,s=>{s.button===0&&(s.preventDefault(),this._sliderPointerDown(s))})),this._onclick(this.slider.domNode,s=>{s.leftButton&&s.stopPropagation()})}_handleElementSize(i){return this._scrollbarState.setVisibleSize(i)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollSize(i){return this._scrollbarState.setScrollSize(i)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_handleElementScrollPosition(i){return this._scrollbarState.setScrollPosition(i)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(i){i.target===this.domNode.domNode&&this._handlePointerDown(i)}delegatePointerDown(i){let e=this.domNode.domNode.getClientRects()[0].top,t=e+this._scrollbarState.getSliderPosition(),r=e+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),s=this._sliderPointerPosition(i);t<=s&&s<=r?i.button===0&&(i.preventDefault(),this._sliderPointerDown(i)):this._handlePointerDown(i)}_handlePointerDown(i){let e,t;if(i.target===this.domNode.domNode&&typeof i.offsetX=="number"&&typeof i.offsetY=="number")e=i.offsetX,t=i.offsetY;else{let s=$s(this.domNode.domNode);e=i.pageX-s.left,t=i.pageY-s.top}let r=this._pointerDownRelativePosition(e,t);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(r):this._scrollbarState.getDesiredScrollPositionFromOffset(r)),i.button===0&&(i.preventDefault(),this._sliderPointerDown(i))}_sliderPointerDown(i){if(!i.target||!(i.target instanceof Element))return;let e=this._sliderPointerPosition(i),t=this._sliderOrthogonalPointerPosition(i),r=this._scrollbarState.clone();this.slider.toggleClassName("xterm-active",!0),this._pointerMoveMonitor.startMonitoring(i.target,i.pointerId,i.buttons,s=>{let o=this._sliderOrthogonalPointerPosition(s),a=Math.abs(o-t);if(Ue&&a>qn){this._setDesiredScrollPositionNow(r.getScrollPosition());return}let h=this._sliderPointerPosition(s)-e;this._setDesiredScrollPositionNow(r.getDesiredScrollPositionFromDelta(h))},()=>{this.slider.toggleClassName("xterm-active",!1),this._host.handleDragEnd()}),this._host.handleDragStart()}_setDesiredScrollPositionNow(i){let e={};this.writeScrollPosition(e,i),this._scrollable.setScrollPositionNow(e)}updateScrollbarSize(i){this._updateScrollbarSize(i),this._scrollbarState.setScrollbarSize(i),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}};var ht=class n{constructor(i,e,t,r,s,o){this._scrollbarSize=Math.round(e),this._oppositeScrollbarSize=Math.round(t),this._arrowSize=Math.round(i),this._visibleSize=r,this._scrollSize=s,this._scrollPosition=o,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new n(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(i){let e=Math.round(i);return this._visibleSize!==e?(this._visibleSize=e,this._refreshComputedValues(),!0):!1}setScrollSize(i){let e=Math.round(i);return this._scrollSize!==e?(this._scrollSize=e,this._refreshComputedValues(),!0):!1}setScrollPosition(i){let e=Math.round(i);return this._scrollPosition!==e?(this._scrollPosition=e,this._refreshComputedValues(),!0):!1}setScrollbarSize(i){this._scrollbarSize=Math.round(i)}setArrowSize(i){let e=Math.round(i);this._arrowSize!==e&&(this._arrowSize=e,this._refreshComputedValues())}setOppositeScrollbarSize(i){this._oppositeScrollbarSize=Math.round(i)}static _computeValues(i,e,t,r,s){let o=Math.max(0,t-i),a=Math.max(0,o-2*e),l=r>0&&r>t;if(!l)return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(a),computedSliderRatio:0,computedSliderPosition:0};let h=Math.round(Math.max(20,Math.floor(t*a/r))),d=(a-h)/(r-t),c=s*d;return{computedAvailableSize:Math.round(o),computedIsNeeded:l,computedSliderSize:Math.round(h),computedSliderRatio:d,computedSliderPosition:Math.round(c)}}_refreshComputedValues(){let i=n._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=i.computedAvailableSize,this._computedIsNeeded=i.computedIsNeeded,this._computedSliderSize=i.computedSliderSize,this._computedSliderRatio=i.computedSliderRatio,this._computedSliderPosition=i.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(i){if(!this._computedIsNeeded)return 0;let e=i-this._arrowSize-this._computedSliderSize/2;return Math.round(e/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(i){if(!this._computedIsNeeded)return 0;let e=i-this._arrowSize,t=this._scrollPosition;return ethis._arrowScroll(-this._arrowScrollDelta)}),this._arrowDown=this._createArrow({className:"xterm-scra xterm-arrow-down",bottom:0,left:0,bgWidth:t,bgHeight:t,handleActivate:()=>this._arrowScroll(this._arrowScrollDelta)})),this._updateArrowSize(this._arrowUp,t),this._updateArrowSize(this._arrowDown,t),!this._arrowUp||!this._arrowDown)return;let r=e?"":"none";this._arrowUp.bgDomNode.style.display=r,this._arrowUp.domNode.style.display=r,this._arrowDown.bgDomNode.style.display=r,this._arrowDown.domNode.style.display=r}_updateArrowSize(e,t){e&&(e.bgDomNode.style.width=`${t}px`,e.bgDomNode.style.height=`${t}px`,e.domNode.style.width=`${t}px`,e.domNode.style.height=`${t}px`)}updateOptions(e){let t=e.verticalHasArrows?e.verticalScrollbarSize:0;this._scrollbarState.setArrowSize(t),this._setArrows(e.verticalHasArrows,e.verticalScrollbarSize),this.updateScrollbarSize(e.vertical===2?0:e.verticalScrollbarSize),this._scrollbarState.setOppositeScrollbarSize(0),this._visibilityController.setVisibility(e.vertical),this._scrollByPage=e.scrollByPage}};var Qr=class{constructor(i,e,t){this.timestamp=i,this.deltaX=e,this.deltaY=t,this.score=0}},Bi=class Bi{constructor(){this._capacity=5,this._memory=[],this._front=-1,this._rear=-1}isPhysicalMouseWheel(){if(this._front===-1&&this._rear===-1)return!1;let i=1,e=0,t=1,r=this._rear;for(;r!==-1;){let s=r===this._front?i:Math.pow(2,-t);if(i-=s,e+=this._memory[r].score*s,r===this._front)break;r=(this._capacity+r-1)%this._capacity,t++}return e<=.5}acceptStandardWheelEvent(i){if(Kt){let e=se(i.browserEvent),t=Xr(e);this.accept(Date.now(),i.deltaX*t,i.deltaY*t)}else this.accept(Date.now(),i.deltaX,i.deltaY)}accept(i,e,t){let r=null,s=new Qr(i,e,t);this._front===-1&&this._rear===-1?(this._memory[0]=s,this._front=0,this._rear=0):(r=this._memory[this._rear],this._rear=(this._rear+1)%this._capacity,this._rear===this._front&&(this._front=(this._front+1)%this._capacity),this._memory[this._rear]=s),s.score=this._computeScore(s,r)}_computeScore(i,e){if(Math.abs(i.deltaX)>0&&Math.abs(i.deltaY)>0)return 1;let t=.5;if((!this._isAlmostInt(i.deltaX)||!this._isAlmostInt(i.deltaY))&&(t+=.25),e){let r=Math.abs(i.deltaX),s=Math.abs(i.deltaY),o=Math.abs(e.deltaX),a=Math.abs(e.deltaY),l=Math.max(Math.min(r,o),1),h=Math.max(Math.min(s,a),1),d=Math.max(r,o),c=Math.max(s,a);d%l===0&&c%h===0&&(t-=.5)}return Math.min(Math.max(t,0),1)}_isAlmostInt(i){return Math.abs(Math.round(i)-i)<.01}};Bi.INSTANCE=new Bi;var es=Bi,Mi=class extends Ne{constructor(e,t,r){super();this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;t=t??{};let s,o=!r;r?s=r:(t.mouseWheelSmoothScroll=!1,s=new lt({forceIntegerValues:!0,smoothScrollDuration:0,scheduleAtNextAnimationFrame:l=>tt(se(e),l)})),this._options=Xn(t),this._scrollable=s,this._register(this._scrollable.onScroll(l=>{this._handleScroll(l),this._onScroll.fire(l)})),o&&this._register(this._scrollable);let a={handleMouseWheel:l=>this._handleMouseWheel(l),handleDragStart:()=>this._handleDragStart(),handleDragEnd:()=>this._handleDragEnd()};this._verticalScrollbar=this._register(new ki(this._scrollable,this._options,a)),this._horizontalScrollbar=this._register(new Ai(this._scrollable,this._options,a)),this._domNode=document.createElement("div"),this._domNode.className="xterm-scrollable-element "+this._options.className,this._domNode.setAttribute("role","presentation"),this._domNode.style.position="relative",this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=new we(document.createElement("div")),this._leftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=new we(document.createElement("div")),this._topShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=new we(document.createElement("div")),this._topLeftShadowDomNode.setClassName("xterm-shadow"),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode??this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this._onmouseover(this._listenOnDomNode,l=>this._handleMouseOver(l)),this._onmouseleave(this._listenOnDomNode,l=>this._handleMouseLeave(l)),this._hideTimeout=this._register(new Ie),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}get options(){return this._options}dispose(){this._mouseWheelToDispose=Oe(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}updateClassName(e){this._options.className=e,ie&&(this._options.className+=" xterm-mac"),this._domNode.className="xterm-scrollable-element "+this._options.className}updateOptions(e){typeof e.handleMouseWheel<"u"&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),typeof e.mouseWheelScrollSensitivity<"u"&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),typeof e.fastScrollSensitivity<"u"&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),typeof e.scrollPredominantAxis<"u"&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),typeof e.horizontal<"u"&&(this._options.horizontal=e.horizontal),typeof e.vertical<"u"&&(this._options.vertical=e.vertical),typeof e.horizontalHasArrows<"u"&&(this._options.horizontalHasArrows=e.horizontalHasArrows),typeof e.verticalHasArrows<"u"&&(this._options.verticalHasArrows=e.verticalHasArrows),typeof e.horizontalScrollbarSize<"u"&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),typeof e.verticalScrollbarSize<"u"&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),typeof e.scrollByPage<"u"&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}delegateScrollFromMouseWheelEvent(e){this._handleMouseWheel(new Gt(e))}_setListeningToMouseWheel(e){if(this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=Oe(this._mouseWheelToDispose),e)){let r=s=>{this._handleMouseWheel(new Gt(s))};this._mouseWheelToDispose.push(C(this._listenOnDomNode,le.MOUSE_WHEEL,r,{passive:!1}))}}_handleMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;let t=es.INSTANCE;t.acceptStandardWheelEvent(e);let r=!1;if(e.deltaY||e.deltaX){let o=e.deltaY*this._options.mouseWheelScrollSensitivity,a=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&a+o===0?a=o=0:Math.abs(o)>=Math.abs(a)?a=0:o=0),this._options.flipAxes&&([o,a]=[a,o]);let l=!ie&&e.browserEvent&&e.browserEvent.shiftKey;(this._options.scrollYToX||l)&&!a&&(a=o,o=0),e.browserEvent&&e.browserEvent.altKey&&(a=a*this._options.fastScrollSensitivity,o=o*this._options.fastScrollSensitivity);let h=this._scrollable.getFutureScrollPosition(),d={};if(o){let c=50*o,u=h.scrollTop-(c<0?Math.floor(c):Math.ceil(c));this._verticalScrollbar.writeScrollPosition(d,u)}if(a){let c=50*a,u=h.scrollLeft-(c<0?Math.floor(c):Math.ceil(c));this._horizontalScrollbar.writeScrollPosition(d,u)}d=this._scrollable.validateScrollPosition(d),(h.scrollLeft!==d.scrollLeft||h.scrollTop!==d.scrollTop)&&(this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(d):this._scrollable.setScrollPositionNow(d),r=!0)}let s=r;!s&&this._options.alwaysConsumeMouseWheel&&(s=!0),!s&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(s=!0),s&&(e.preventDefault(),e.stopPropagation())}_handleScroll(e){this._shouldRender=this._horizontalScrollbar.handleScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.handleScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw new Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){let e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,r=e.scrollLeft>0,s=r?" xterm-shadow-left":"",o=t?" xterm-shadow-top":"",a=r||t?" xterm-shadow-top-left-corner":"";this._leftShadowDomNode.setClassName(`xterm-shadow${s}`),this._topShadowDomNode.setClassName(`xterm-shadow${o}`),this._topLeftShadowDomNode.setClassName(`xterm-shadow${a}${o}${s}`)}}_handleDragStart(){this._isDragging=!0,this._reveal()}_handleDragEnd(){this._isDragging=!1,this._hide()}_handleMouseLeave(e){this._mouseIsOver=!1,this._hide()}_handleMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){!this._mouseIsOver&&!this._isDragging&&(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){!this._mouseIsOver&&!this._isDragging&&this._hideTimeout.cancelAndSet(()=>this._hide(),500)}};function Xn(n){let i={lazyRender:typeof n.lazyRender<"u"?n.lazyRender:!1,className:typeof n.className<"u"?n.className:"",useShadows:typeof n.useShadows<"u"?n.useShadows:!0,handleMouseWheel:typeof n.handleMouseWheel<"u"?n.handleMouseWheel:!0,flipAxes:typeof n.flipAxes<"u"?n.flipAxes:!1,consumeMouseWheelIfScrollbarIsNeeded:typeof n.consumeMouseWheelIfScrollbarIsNeeded<"u"?n.consumeMouseWheelIfScrollbarIsNeeded:!1,alwaysConsumeMouseWheel:typeof n.alwaysConsumeMouseWheel<"u"?n.alwaysConsumeMouseWheel:!1,scrollYToX:typeof n.scrollYToX<"u"?n.scrollYToX:!1,mouseWheelScrollSensitivity:typeof n.mouseWheelScrollSensitivity<"u"?n.mouseWheelScrollSensitivity:1,fastScrollSensitivity:typeof n.fastScrollSensitivity<"u"?n.fastScrollSensitivity:5,scrollPredominantAxis:typeof n.scrollPredominantAxis<"u"?n.scrollPredominantAxis:!0,mouseWheelSmoothScroll:typeof n.mouseWheelSmoothScroll<"u"?n.mouseWheelSmoothScroll:!0,listenOnDomNode:typeof n.listenOnDomNode<"u"?n.listenOnDomNode:null,horizontal:typeof n.horizontal<"u"?n.horizontal:1,horizontalScrollbarSize:typeof n.horizontalScrollbarSize<"u"?n.horizontalScrollbarSize:10,horizontalSliderSize:typeof n.horizontalSliderSize<"u"?n.horizontalSliderSize:0,horizontalHasArrows:typeof n.horizontalHasArrows<"u"?n.horizontalHasArrows:!1,vertical:typeof n.vertical<"u"?n.vertical:1,verticalScrollbarSize:typeof n.verticalScrollbarSize<"u"?n.verticalScrollbarSize:10,verticalHasArrows:typeof n.verticalHasArrows<"u"?n.verticalHasArrows:!1,verticalSliderSize:typeof n.verticalSliderSize<"u"?n.verticalSliderSize:0,scrollByPage:typeof n.scrollByPage<"u"?n.scrollByPage:!1};return i.horizontalSliderSize=typeof n.horizontalSliderSize<"u"?n.horizontalSliderSize:i.horizontalScrollbarSize,i.verticalSliderSize=typeof n.verticalSliderSize<"u"?n.verticalSliderSize:i.verticalScrollbarSize,ie&&(i.className+=" xterm-mac"),i}var dt=class extends g{constructor(e,t,r,s,o,a,l,h,d){super();this._bufferService=r;this._coreService=o;this._optionsService=h;this._renderService=d;this._onRequestScrollLines=this._register(new b);this.onRequestScrollLines=this._onRequestScrollLines.event;this._isSyncing=!1;this._isHandlingScroll=!1;this._suppressOnScrollHandler=!1;this._needsSyncOnRender=!1;let c=this._register(new lt({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:u=>tt(s.window,u)}));this._register(this._optionsService.onSpecificOptionChange("smoothScrollDuration",()=>{c.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new Mi(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,verticalHasArrows:this._optionsService.rawOptions.scrollbar?.showArrows??!1,...this._getChangeOptions()},c)),this._register(this._optionsService.onMultipleOptionChange(["scrollSensitivity","fastScrollSensitivity","scrollbar"],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(a.onProtocolChange(u=>{this._scrollableElement.updateOptions({handleMouseWheel:!(u&16)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(j.runAndSubscribe(l.onChangeColors,()=>{e.style.backgroundColor=l.colors.background.css,this._scrollableElement.getDomNode().style.backgroundColor=l.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register(E(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=s.mainDocument.createElement("style"),t.appendChild(this._styleElement),this._register(E(()=>this._styleElement.remove())),this._register(j.runAndSubscribe(l.onChangeColors,()=>{this._styleElement.textContent=[".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {",` background: ${l.colors.scrollbarSliderBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {",` background: ${l.colors.scrollbarSliderHoverBackground.css};`,"}",".xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {",` background: ${l.colors.scrollbarSliderActiveBackground.css};`,"}"].join(` -`)})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(u=>this._handleScroll(u)))}scrollLines(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){let e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,r=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:r,verticalHasArrows:t}}queueSync(e){e!==void 0&&(this._latestYDisp=e),this._queuedAnimationFrame===void 0&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){if(!(!this._renderService||this._isSyncing)){if(this._coreService.decPrivateModes.synchronizedOutput){this._needsSyncOnRender=!0;return}this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1}}_handleScroll(e){if(!this._renderService||this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;let t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),r=t-this._bufferService.buffer.ydisp;r!==0&&(this._latestYDisp=t,this._onRequestScrollLines.fire(r)),this._isHandlingScroll=!1}handleTouchScroll(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};dt=y([m(2,D),m(3,G),m(4,Y),m(5,Me),m(6,_e),m(7,R),m(8,V)],dt);var ut=class extends g{constructor(e,t,r,s,o){super();this._screenElement=e;this._bufferService=t;this._coreBrowserService=r;this._decorationService=s;this._renderService=o;this._decorationElements=new Map;this._altBufferIsActive=!1;this._dimensionsChanged=!1;this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(a=>this._removeDecoration(a))),this._register(E(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(let e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){let t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",e?.options?.layer==="top"),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,t.style.top=`${(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height}px`,t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;let r=e.options.x??0;return r&&r>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){let t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let r=this._decorationElements.get(e);r||(r=this._createElement(e),e.element=r,this._decorationElements.set(e,r),this._container.appendChild(r),e.onDispose(()=>{this._decorationElements.delete(e),r.remove()})),r.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(r.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,r.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,r.style.top=`${t*this._renderService.dimensions.css.cell.height}px`,r.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(r)}}_refreshXPosition(e,t=e.element){if(!t)return;let r=e.options.x??0;(e.options.anchor||"left")==="right"?t.style.right=r?`${r*this._renderService.dimensions.css.cell.width}px`:"":t.style.left=r?`${r*this._renderService.dimensions.css.cell.width}px`:""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};ut=y([m(1,D),m(2,G),m(3,ge),m(4,V)],ut);var Pi=class{constructor(){this._zones=[];this._zonePool=[];this._zonePoolIndex=0;this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(i){if(i.options.overviewRulerOptions){for(let e of this._zones)if(e.color===i.options.overviewRulerOptions.color&&e.position===i.options.overviewRulerOptions.position){if(this._lineIntersectsZone(e,i.marker.line))return;if(this._lineAdjacentToZone(e,i.marker.line,i.options.overviewRulerOptions.position)){this._addLineToZone(e,i.marker.line);return}}if(this._zonePoolIndex=i.startBufferLine&&e<=i.endBufferLine}_lineAdjacentToZone(i,e,t){return e>=i.startBufferLine-this._linePadding[t||"full"]&&e<=i.endBufferLine+this._linePadding[t||"full"]}_addLineToZone(i,e){i.startBufferLine=Math.min(i.startBufferLine,e),i.endBufferLine=Math.max(i.endBufferLine,e)}};var Ce={full:0,left:0,center:0,right:0},Fe={full:0,left:0,center:0,right:0},$t={full:0,left:0,center:0,right:0},ze=class extends g{constructor(e,t,r,s,o,a,l,h){super();this._viewportElement=e;this._screenElement=t;this._bufferService=r;this._decorationService=s;this._renderService=o;this._optionsService=a;this._themeService=l;this._coreBrowserService=h;this._colorZoneStore=new Pi;this._shouldUpdateDimensions=!0;this._shouldUpdateAnchor=!0;this._lastKnownBufferLength=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register(E(()=>this._canvas?.remove()));let d=this._canvas.getContext("2d");if(d)this._ctx=d;else throw new Error("Ctx cannot be null");this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register(E(()=>{this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}get _width(){let e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??!0?e?.width??0:0}_refreshDrawConstants(){let e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);Fe.full=this._canvas.width,Fe.left=e,Fe.center=t,Fe.right=e,this._refreshDrawHeightConstants(),$t.full=1,$t.left=1,$t.center=1+Fe.left,$t.right=1+Fe.left+Fe.center}_refreshDrawHeightConstants(){Ce.full=Math.round(2*this._coreBrowserService.dpr);let e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);Ce.left=t,Ce.center=t,Ce.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;let e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(let t of this._decorationService.decorations)this._colorZoneStore.addDecoration(t);this._ctx.lineWidth=1,this._renderRulerOutline();let e=this._colorZoneStore.zones;for(let t of e)t.position!=="full"&&this._renderColorZone(t);for(let t of e)t.position==="full"&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect($t[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-Ce[e.position||"full"]/2),Fe[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+Ce[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};ze=y([m(2,D),m(3,ge),m(4,V),m(5,R),m(6,_e),m(7,G)],ze);var ft=class{constructor(i,e,t,r,s,o){this._textarea=i;this._compositionView=e;this._bufferService=t;this._optionsService=r;this._coreService=s;this._renderService=o;this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent=""}get isComposing(){return this._isComposing}compositionstart(){this._isComposing=!0;let i=this._textarea.selectionStart??this._textarea.value.length,e=this._textarea.selectionEnd??i;this._compositionPosition.start=Math.min(i,e),this._compositionPosition.end=Math.max(i,e),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(i){this._compositionView.textContent=`\u200E${i.data}\u200E`,this.updateCompositionElements(),setTimeout(()=>{let e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(i){if(this._isComposing||this._isSendingComposition){if(i.keyCode===20||i.keyCode===229||i.keyCode===16||i.keyCode===17||i.keyCode===18)return!1;this._finalizeComposition(!1)}return i.keyCode===229?(this._handleAnyTextareaChanges(),!1):!0}_finalizeComposition(i){if(this._compositionView.classList.remove("active"),this._isComposing=!1,i){let e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){this._isSendingComposition=!1;let r;if(e.start+=this._dataAlreadySent.length,this._isComposing)r=this._textarea.value.substring(e.start,this._compositionPosition.start);else{let s=this._textarea.value,o=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;r=s.substring(e.start,Math.max(e.start,o))}r.length>0&&this._coreService.triggerDataEvent(r,!0)}},0)}else{this._isSendingComposition=!1;let e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;let i=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){let e=this._textarea.value,t=e.replace(i,"");this._dataAlreadySent=t,e.length>i.length?this._coreService.triggerDataEvent(t,!0):e.lengththis.updateCompositionElements(!0),0)}}};ft=y([m(2,D),m(3,R),m(4,Y),m(5,V)],ft);var J=0,Q=0,ee=0,W=0,ts={css:"#00000000",rgba:0},O;(t=>{function n(r,s,o,a){return a!==void 0?`#${Ve(r)}${Ve(s)}${Ve(o)}${Ve(a)}`:`#${Ve(r)}${Ve(s)}${Ve(o)}`}t.toCss=n;function i(r,s,o,a=255){return(r<<24|s<<16|o<<8|a)>>>0}t.toRgba=i;function e(r,s,o,a){return{css:t.toCss(r,s,o,a),rgba:t.toRgba(r,s,o,a)}}t.toColor=e})(O||={});var k;(a=>{function n(l,h){if(W=(h.rgba&255)/255,W===1)return{css:h.css,rgba:h.rgba};let d=h.rgba>>24&255,c=h.rgba>>16&255,u=h.rgba>>8&255,_=l.rgba>>24&255,p=l.rgba>>16&255,v=l.rgba>>8&255;J=_+Math.round((d-_)*W),Q=p+Math.round((c-p)*W),ee=v+Math.round((u-v)*W);let f=O.toCss(J,Q,ee),S=O.toRgba(J,Q,ee);return{css:f,rgba:S}}a.blend=n;function i(l){return(l.rgba&255)===255}a.isOpaque=i;function e(l,h,d){let c=Oi.ensureContrastRatio(l.rgba,h.rgba,d);if(c)return O.toColor(c>>24&255,c>>16&255,c>>8&255)}a.ensureContrastRatio=e;function t(l){let h=(l.rgba|255)>>>0;return[J,Q,ee]=Oi.toChannels(h),{css:O.toCss(J,Q,ee),rgba:h}}a.opaque=t;function r(l,h){return W=Math.round(h*255),[J,Q,ee]=Oi.toChannels(l.rgba),{css:O.toCss(J,Q,ee,W),rgba:O.toRgba(J,Q,ee,W)}}a.opacity=r;function s(l,h){return W=l.rgba&255,r(l,W*h/255)}a.multiplyOpacity=s;function o(l){return[l.rgba>>24&255,l.rgba>>16&255,l.rgba>>8&255]}a.toColorRGB=o})(k||={});var B;(t=>{let n,i;try{let r=document.createElement("canvas");r.width=1,r.height=1;let s=r.getContext("2d",{willReadFrequently:!0});s&&(n=s,n.globalCompositeOperation="copy",i=n.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),O.toColor(J,Q,ee);case 5:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),W=parseInt(r.slice(4,5).repeat(2),16),O.toColor(J,Q,ee,W);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let s=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return J=parseInt(s[1],10),Q=parseInt(s[2],10),ee=parseInt(s[3],10),W=Math.round((s[5]===void 0?1:parseFloat(s[5]))*255),O.toColor(J,Q,ee,W);if(r==="transparent")return{css:"transparent",rgba:0};if(!n||!i)throw new Error("css.toColor: Unsupported css format");if(n.fillStyle=i,n.fillStyle=r,typeof n.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(n.fillRect(0,0,1,1),[J,Q,ee,W]=n.getImageData(0,0,1,1).data,W!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:O.toRgba(J,Q,ee,W),css:r}}t.toColor=e})(B||={});var Z;(e=>{function n(t){return i(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=n;function i(t,r,s){let o=t/255,a=r/255,l=s/255,h=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),d=a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4),c=l<=.03928?l/12.92:Math.pow((l+.055)/1.055,2.4);return h*.2126+d*.7152+c*.0722}e.relativeLuminance2=i})(Z||={});var Oi;(s=>{function n(o,a){if(W=(a&255)/255,W===1)return a;let l=a>>24&255,h=a>>16&255,d=a>>8&255,c=o>>24&255,u=o>>16&255,_=o>>8&255;return J=c+Math.round((l-c)*W),Q=u+Math.round((h-u)*W),ee=_+Math.round((d-_)*W),O.toRgba(J,Q,ee)}s.blend=n;function i(o,a,l){let h=Z.relativeLuminance(o>>8),d=Z.relativeLuminance(a>>8);if(Te(h,d)>8));if(v>8));return v>S?p:f}return p}let u=t(o,a,l),_=Te(h,Z.relativeLuminance(u>>8));if(_>8));return _>v?u:p}return u}}s.ensureContrastRatio=i;function e(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v0||_>0||p>0);)u-=Math.max(0,Math.ceil(u*.1)),_-=Math.max(0,Math.ceil(_*.1)),p-=Math.max(0,Math.ceil(p*.1)),v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));return(u<<24|_<<16|p<<8|255)>>>0}s.reduceLuminance=e;function t(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v>>0}s.increaseLuminance=t;function r(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}s.toChannels=r})(Oi||={});function Ve(n){let i=n.toString(16);return i.length<2?"0"+i:i}function Te(n,i){return n1){let u=this._getJoinedRanges(r,l,a,e,o);for(let _=0;_1){let c=this._getJoinedRanges(r,l,a,e,o);for(let u=0;u=ks,Lr=ae,x=this._workCell;if(v.length>0&&ae===v[0][0]&&je){let A=v.shift(),Pr=this._isCellInSelection(A[0],e);for(T=A[0]+1;T=A[1],je?(fi=!0,x=new Ni(this._workCell,i.translateToString(!0,A[0],A[1]),A[1]-A[0]),Lr=A[1]-1,Rr=x.getWidth()):ks=A[1]}let Nt=this._isCellInSelection(ae,e),Ar=t&&ae===o,kr=An&&ae>=c&&ae<=u;_&&x.isBlink()&&(_.hasBlinkingCells=!0),!l&&x.isBlink()&&N.push("xterm-blink-hidden");let Mr=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{Mr=!0});let _i=x.getChars()||" ";if(_i===" "&&(x.isUnderline()||x.isOverline())&&(_i="\xA0"),Ot=Rr*h-d.get(_i,x.isBold(),x.isItalic()),!I)I=this._document.createElement("span");else if(w&&(Nt&&ui||!Nt&&!ui&&x.bg===te)&&(Nt&&ui&&f.selectionForeground||x.fg===Ds)&&x.extended.ext===Rs&&kr===Ls&&Ot===As&&!Ar&&!fi&&!Mr&&je){x.isInvisible()?L+=" ":L+=_i,w++;continue}else w&&(I.textContent=L),I=this._document.createElement("span"),w=0,L="";if(te=x.bg,Ds=x.fg,Rs=x.extended.ext,Ls=kr,As=Ot,ui=Nt,fi&&o>=ae&&o<=Lr&&(o=ae),!this._coreService.isCursorHidden&&Ar&&this._coreService.isCursorInitialized){if(N.push("xterm-cursor"),this._coreBrowserService.isFocused)a&&N.push("xterm-cursor-blink"),N.push(r==="bar"?"xterm-cursor-bar":r==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(s)switch(s){case"outline":N.push("xterm-cursor-outline");break;case"block":N.push("xterm-cursor-block");break;case"bar":N.push("xterm-cursor-bar");break;case"underline":N.push("xterm-cursor-underline");break;default:break}}if(x.isBold()&&N.push("xterm-bold"),x.isItalic()&&N.push("xterm-italic"),x.isDim()&&N.push("xterm-dim"),x.isInvisible()?L=" ":L=x.getChars()||" ",x.isUnderline()&&(N.push(`xterm-underline-${x.extended.underlineStyle}`),L===" "&&(L="\xA0"),!x.isUnderlineColorDefault()))if(x.isUnderlineColorRGB())I.style.textDecorationColor=`rgb(${ue.toColorRGB(x.getUnderlineColor()).join(",")})`;else{let A=x.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&x.isBold()&&A<8&&(A+=8),I.style.textDecorationColor=f.ansi[A].css}x.isOverline()&&(N.push("xterm-overline"),L===" "&&(L="\xA0")),x.isStrikethrough()&&N.push("xterm-strikethrough"),kr&&(I.style.textDecoration="underline");let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();if(Br){let A=de;de=Se,Se=A;let Pr=Ft;Ft=Ht,Ht=Pr}let Le,pi,Wt=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{A.options.layer!=="top"&&Wt||(A.backgroundColorRGB&&(Ht=50331648,Se=A.backgroundColorRGB.rgba>>8&16777215,Le=A.backgroundColorRGB),A.foregroundColorRGB&&(Ft=50331648,de=A.foregroundColorRGB.rgba>>8&16777215,pi=A.foregroundColorRGB),Wt=A.options.layer==="top")}),!Wt&&Nt&&(Le=this._coreBrowserService.isFocused?f.selectionBackgroundOpaque:f.selectionInactiveBackgroundOpaque,Se=Le.rgba>>8&16777215,Ht=50331648,Wt=!0,f.selectionForeground&&(Ft=50331648,de=f.selectionForeground.rgba>>8&16777215,pi=f.selectionForeground)),Wt&&N.push("xterm-decoration-top");let Ae;switch(Ht){case 16777216:case 33554432:Ae=f.ansi[Se],N.push(`xterm-bg-${Se}`);break;case 50331648:Ae=O.toColor(Se>>16,Se>>8&255,Se&255),this._addStyle(I,`background-color:#${(Se>>>0).toString(16).padStart(6,"0")}`);break;case 0:default:Br?(Ae=f.foreground,N.push(`xterm-bg-${257}`)):Ae=f.background}switch(Le||x.isDim()&&(Le=k.multiplyOpacity(Ae,.5)),Ft){case 16777216:case 33554432:x.isBold()&&de<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(de+=8),this._applyMinimumContrast(I,Ae,f.ansi[de],x,Le,void 0)||N.push(`xterm-fg-${de}`);break;case 50331648:let A=O.toColor(de>>16&255,de>>8&255,de&255);this._applyMinimumContrast(I,Ae,A,x,Le,pi)||this._addStyle(I,`color:#${de.toString(16).padStart(6,"0")}`);break;case 0:default:this._applyMinimumContrast(I,Ae,f.foreground,x,Le,pi)||Br&&N.push(`xterm-fg-${257}`)}N.length&&(I.className=N.join(" "),N.length=0),!Ar&&!fi&&!Mr&&je?w++:I.textContent=L,Ot!==this.defaultSpacing&&(I.style.letterSpacing=`${Ot}px`),p.push(I),ae=Lr}return I&&w&&(I.textContent=L),p}_applyMinimumContrast(i,e,t,r,s,o){if(this._optionsService.rawOptions.minimumContrastRatio===1||js(r.getCode()))return!1;let a=this._getContrastCache(r),l;if(!s&&!o&&(l=a.getColor(e.rgba,t.rgba)),l===void 0){let h=this._optionsService.rawOptions.minimumContrastRatio/(r.isDim()?2:1);l=k.ensureContrastRatio(s??e,o??t,h),a.setColor((s??e).rgba,(o??t).rgba,l??null)}return l?(this._addStyle(i,`color:${l.css}`),!0):!1}_getContrastCache(i){return i.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(i,e){i.setAttribute("style",`${i.getAttribute("style")||""}${e};`)}_isCellInSelection(i,e){let t=this._selectionStart,r=this._selectionEnd;return!t||!r?!1:this._columnSelectMode?t[0]<=r[0]?i>=t[0]&&e>=t[1]&&i=t[1]&&i>=r[0]&&e<=r[1]:e>t[1]&&e=t[0]&&i=t[0]}};_t=y([m(1,gi),m(2,R),m(3,G),m(4,Y),m(5,ge),m(6,_e)],_t);var Hi=class{constructor(i=()=>new rs){this._flat=new Float32Array(256);this._font="";this._fontSize=0;this._weight="normal";this._weightBold="bold";this._canvasElements=[];this._canvasElements=[i(),i(),i(),i()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(i,e,t,r){i===this._font&&e===this._fontSize&&t===this._weight&&r===this._weightBold||(this._font=i,this._fontSize=e,this._weight=t,this._weightBold=r,this._canvasElements[0].setFont(i,e,t,!1),this._canvasElements[1].setFont(i,e,r,!1),this._canvasElements[2].setFont(i,e,t,!0),this._canvasElements[3].setFont(i,e,r,!0),this.clear())}get(i,e,t){let r;if(!e&&!t&&i.length===1&&(r=i.charCodeAt(0))<256){if(this._flat[r]!==-9999)return this._flat[r];let a=this._measure(i,0);return a>0&&(this._flat[r]=a),a}let s=i;e&&(s+="B"),t&&(s+="I");let o=this._holey.get(s);if(o===void 0){let a=0;e&&(a|=1),t&&(a|=2),o=this._measure(i,a),o>0&&this._holey.set(s,o)}return o}_measure(i,e){return this._canvasElements[e].measure(i)}},rs=class{constructor(){typeof OffscreenCanvas<"u"?(this._canvas=new OffscreenCanvas(1,1),this._ctx=is(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=is(this._canvas.getContext("2d")))}setFont(i,e,t,r){let s=r?"italic":"";this._ctx.font=`${s} ${t} ${e}px ${i}`.trim()}measure(i){return this._ctx.measureText(i).width}};var ss=class{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(i,e,t,r=!1){if(this.selectionStart=e,this.selectionEnd=t,!e||!t||e[0]===t[0]&&e[1]===t[1]){this.clear();return}let s=i.buffers.active.ydisp,o=e[1]-s,a=t[1]-s,l=Math.max(o,0),h=Math.min(a,i.rows-1);if(l>=i.rows||h<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=r,this.viewportStartRow=o,this.viewportEndRow=a,this.viewportCappedStartRow=l,this.viewportCappedEndRow=h,this.startCol=e[0],this.endCol=t[0]}isCellSelected(i,e,t){return this.hasSelection?(t-=i.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&t>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&t<=this.viewportCappedEndRow:t>this.viewportStartRow&&t=this.startCol&&e=this.startCol):!1}};function Js(){return new ss}var Wi=class extends g{constructor(e,t,r){super();this._renderCallback=e;this._coreBrowserService=t;this._optionsService=r;this._intervalDuration=0;this._blinkOn=!0;this._needsBlinkInViewport=!1;this._isViewportVisible=!0;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",s=>{this.setIntervalDuration(s)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(E(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let t=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),t||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var Zn=1,mt=class extends g{constructor(e,t,r,s,o,a,l,h,d,c,u,_,p,v){super();this._terminal=e;this._document=t;this._element=r;this._screenElement=s;this._viewportElement=o;this._helperContainer=a;this._linkifier2=l;this._charSizeService=d;this._optionsService=c;this._bufferService=u;this._coreService=_;this._coreBrowserService=p;this._themeService=v;this._terminalClass=Zn++;this._rowElements=[];this._selectionRenderModel=Js();this._lastSelectionColumnMode=!1;this._rowHasBlinkingCells=[];this._rowHasBlinkingCellsCount=0;this._onRequestRedraw=this._register(new b);this.onRequestRedraw=this._onRequestRedraw.event;this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=Zs(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(f=>this._injectCss(f))),this._injectCss(this._themeService.colors),this._rowFactory=h.createInstance(_t,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(f=>this._handleLinkHover(f))),this._register(this._linkifier2.onHideLinkUnderline(f=>this._handleLinkLeave(f))),this._cursorBlinkStateManager=new ns(this._rowContainer,this._coreBrowserService),this._register(C(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register(E(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new Wi(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register(E(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new Hi,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){let e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(let r of this._rowElements)r.style.width=`${this.dimensions.css.canvas.width}px`,r.style.height=`${this.dimensions.css.cell.height}px`,r.style.lineHeight=`${this.dimensions.css.cell.height}px`,r.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));let t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${k.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;let r=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,o=`blink_block_${this._terminalClass}`;t+=`@keyframes ${r} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${o} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${o} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(let[a,l]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${a} { color: ${l.css}; }${this._terminalSelector} .xterm-fg-${a}.xterm-dim { color: ${k.multiplyOpacity(l,.5).css}; }${this._terminalSelector} .xterm-bg-${a} { background-color: ${l.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${257} { color: ${k.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${257}.xterm-dim { color: ${k.multiplyOpacity(k.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${257} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){let e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let r=this._rowElements.length;r<=t;r++){let s=this._document.createElement("div");this._rowContainer.appendChild(s),this._rowElements.push(s),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,r){let s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,r);let o=0,a=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(o=this._selectionRenderModel.viewportCappedStartRow,a=this._selectionRenderModel.viewportCappedEndRow));let l=0,h=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,r),this._selectionRenderModel.hasSelection){let u=this._selectionRenderModel.viewportStartRow,_=this._selectionRenderModel.viewportEndRow,p=this._selectionRenderModel.viewportCappedStartRow,v=this._selectionRenderModel.viewportCappedEndRow;l=p,h=v;let f=this._document.createDocumentFragment();if(r){let S=e[0]>t[0];f.appendChild(this._createSelectionElement(p,S?t[0]:e[0],S?e[0]:t[0],v-p+1))}else{let S=u===p?e[0]:0,I=p===_?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(p,S,I));let w=v-p-1;if(f.appendChild(this._createSelectionElement(p+1,0,this._bufferService.cols,w)),p!==v){let L=_===v?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(v,0,L))}}this._selectionContainer.appendChild(f)}let d=Math.min(o,l),c=Math.max(a,h);if(c>=0){d=Math.max(d,0),c=Math.min(c,s-1);let _=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&_>=0&&_this.dimensions.css.canvas.width&&(l=this.dimensions.css.canvas.width-a),o.style.height=`${s*this.dimensions.css.cell.height}px`,o.style.top=`${e*this.dimensions.css.cell.height}px`,o.style.left=`${a}px`,o.style.width=`${l}px`,o}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(let e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){let r=this._bufferService.buffer,s=r.ybase+r.y,o=Math.min(r.x,this._bufferService.cols-1),a=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,l=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,h=this._optionsService.rawOptions.cursorInactiveStyle,d={hasBlinkingCells:!1};for(let c=e;c<=t;c++){let u=c+r.ydisp,_=this._rowElements[c];if(!_)continue;let p=r.lines.get(u);if(!p){_.replaceChildren(),this._setRowBlinkState(c,!1);continue}_.replaceChildren(...this._rowFactory.createRow(p,u,u===s,l,h,o,a,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,d)),this._setRowBlinkState(c,d.hasBlinkingCells)}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,r,s,o,a){r<0&&(e=0),s<0&&(t=0);let l=this._bufferService.rows-1;r=Math.max(Math.min(r,l),0),s=Math.max(Math.min(s,l),0),o=Math.min(o,this._bufferService.cols);let h=this._bufferService.buffer,d=h.ybase+h.y,c=Math.min(h.x,o-1),u=this._optionsService.rawOptions.cursorBlink,_=this._optionsService.rawOptions.cursorStyle,p=this._optionsService.rawOptions.cursorInactiveStyle,v={hasBlinkingCells:!1};for(let f=r;f<=s;++f){let S=f+h.ydisp,I=this._rowElements[f];if(!I)continue;let w=h.lines.get(S);if(!w){I.replaceChildren(),this._setRowBlinkState(f,!1);continue}I.replaceChildren(...this._rowFactory.createRow(w,S,S===d,_,p,c,u,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,a?f===r?e:0:-1,a?(f===s?t:o)-1:-1,v)),this._setRowBlinkState(f,v.hasBlinkingCells)}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};mt=y([m(7,Qe),m(8,Be),m(9,R),m(10,D),m(11,Y),m(12,G),m(13,_e)],mt);var ns=class{constructor(i,e){this._rowContainer=i;this._coreBrowserService=e;this._isIdlePaused=!1;this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){this._idleTimeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}};var bt=class extends g{constructor(e,t,r){super();this._optionsService=r;this.width=0;this.height=0;this._onCharSizeChange=this._register(new b);this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new as(this._optionsService))}catch{this._measureStrategy=this._register(new os(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}get hasValidSize(){return this.width>0&&this.height>0}measure(){let e=this._measureStrategy.measure();(e.width!==this.width||e.height!==this.height)&&(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};bt=y([m(2,R)],bt);var Ui=class extends g{constructor(){super(...arguments);this._result={width:0,height:0}}_validateAndSet(e,t){e!==void 0&&e>0&&t!==void 0&&t>0&&(this._result.width=e,this._result.height=t)}},os=class extends Ui{constructor(e,t,r){super();this._document=e;this._parentElement=t;this._optionsService=r;this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}},as=class extends Ui{constructor(e){super();this._optionsService=e;this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");let t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;let e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}};var Ki=class extends g{constructor(e,t,r){super();this._textarea=e;this._window=t;this.mainDocument=r;this._isFocused=!1;this._cachedIsFocused=void 0;this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._onWindowChange=this._register(new b);this.onWindowChange=this._onWindowChange.event;this._screenDprMonitor=this._register(new ls(this._window)),this._register(this.onWindowChange(s=>this._screenDprMonitor.setWindow(s))),this._register(j.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register(C(this._textarea,"focus",()=>this._isFocused=!0)),this._register(C(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}},ls=class extends g{constructor(e){super();this._parentWindow=e;this._windowResizeListener=this._register(new P);this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register(E(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=C(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){!this._resolutionMediaMatchList||!this._outerListener||(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}};var zi=class extends g{constructor(){super();this.linkProviders=[];this._register(E(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{let t=this.linkProviders.indexOf(e);t!==-1&&this.linkProviders.splice(t,1)}}}};function qt(n,i,e){let t=e.getBoundingClientRect(),r=n.getComputedStyle(e),s=parseInt(r.getPropertyValue("padding-left"),10),o=parseInt(r.getPropertyValue("padding-top"),10);return[i.clientX-t.left-s,i.clientY-t.top-o]}function Qs(n,i,e,t,r,s,o,a,l){if(!s)return;let h=qt(n,i,e);return h[0]=Math.ceil((h[0]+(l?o/2:0))/o),h[1]=Math.ceil(h[1]/a),h[0]=Math.min(Math.max(h[0],1),t+(l?1:0)),h[1]=Math.min(Math.max(h[1],1),r),h}var vt=class{constructor(i,e){this._charSizeService=i;this._renderService=e}getCoords(i,e,t,r,s){return Qs(se(e),i,e,t,r,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,s)}getMouseReportCoords(i,e){let t=qt(se(e),i,e);if(this._charSizeService.hasValidSize)return t[0]=Math.min(Math.max(t[0],0),this._renderService.dimensions.css.canvas.width-1),t[1]=Math.min(Math.max(t[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(t[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(t[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(t[0]),y:Math.floor(t[1])}}};vt=y([m(0,Be),m(1,V)],vt);var en=typeof window=="object"?window:globalThis;function ce(n,i=0){return n[n.length-(1+i)]}function Jn(n,i,e){let t=null,r=null;if(typeof e.value=="function"?(t="value",r=e.value,r.length!==0&&console.warn("Memoize should only be used in functions with zero parameters")):typeof e.get=="function"&&(t="get",r=e.get),!r||!t)throw new Error("not supported");let s=`$memoize$${i}`,o=e;o[t]=function(...a){return this.hasOwnProperty(s)||Object.defineProperty(this,s,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,a)}),this[s]}}var St=class St{constructor(i){this.element=i,this.next=St.Undefined,this.prev=St.Undefined}};St.Undefined=new St(void 0);var re=St,Gi=class{constructor(){this._first=re.Undefined;this._last=re.Undefined}push(i){return this._insert(i,!0)}_insert(i,e){let t=new re(i);if(this._first===re.Undefined)this._first=t,this._last=t;else if(e){let s=this._last;this._last=t,t.prev=s,s.next=t}else{let s=this._first;this._first=t,t.next=s,s.prev=t}let r=!1;return()=>{r||(r=!0,this._remove(t))}}_remove(i){if(i.prev!==re.Undefined&&i.next!==re.Undefined){let e=i.prev;e.next=i.next,i.next.prev=e}else i.prev===re.Undefined&&i.next===re.Undefined?(this._first=re.Undefined,this._last=re.Undefined):i.next===re.Undefined?(this._last=this._last.prev,this._last.next=re.Undefined):i.prev===re.Undefined&&(this._first=this._first.next,this._first.prev=re.Undefined)}*[Symbol.iterator](){let i=this._first;for(;i!==re.Undefined;)yield i.element,i=i.next}},he;(s=>(s.TAP="-xterm-gesturetap",s.CHANGE="-xterm-gesturechange",s.START="-xterm-gesturestart",s.END="-xterm-gesturesend",s.CONTEXT_MENU="-xterm-gesturecontextmenu"))(he||={});var K=class K extends g{constructor(){super();this._dispatched=!1;this._targets=new Gi;this._ignoreTargets=new Gi;this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;let e=en;this._register(C(e.document,"touchstart",t=>this._handleTouchStart(t),{passive:!1})),this._register(C(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(C(e.document,"touchmove",t=>this._handleTouchMove(t),{passive:!1}))}static addTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._targets.push(e);return E(t)}static ignoreTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._ignoreTargets.push(e);return E(t)}static isTouchDevice(){return"ontouchstart"in en||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){let t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let r=0,s=e.targetTouches.length;r=K._holdDelay&&Math.abs(h.initialPageX-ce(h.rollingPageX))<30&&Math.abs(h.initialPageY-ce(h.rollingPageY))<30){let c=this._newGestureEvent(he.CONTEXT_MENU,h.initialTarget);c.pageX=ce(h.rollingPageX),c.pageY=ce(h.rollingPageY),this._dispatchEvent(c)}else if(s===1){let c=ce(h.rollingPageX),u=ce(h.rollingPageY),_=ce(h.rollingTimestamps)-h.rollingTimestamps[0],p=c-h.rollingPageX[0],v=u-h.rollingPageY[0],f=[...this._targets].filter(S=>h.initialTarget instanceof Node&&S.contains(h.initialTarget));this._inertia(e,f,r,Math.abs(p)/_,p>0?1:-1,c,Math.abs(v)/_,v>0?1:-1,u)}this._dispatchEvent(this._newGestureEvent(he.END,h.initialTarget)),delete this._activeTouches[l.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){let r=document.createEvent("CustomEvent");return r.initEvent(e,!1,!0),r.initialTarget=t,r.tapCount=0,r}_dispatchEvent(e){if(e.type===he.TAP){let t=new Date().getTime(),r;t-this._lastSetTapCountTime>K._clearTapCountTime?r=1:r=2,this._lastSetTapCountTime=t,e.tapCount=r}else(e.type===he.CHANGE||e.type===he.CONTEXT_MENU)&&(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(let r of this._ignoreTargets)if(r.contains(e.initialTarget))return;let t=[];for(let r of this._targets)if(r.contains(e.initialTarget)){let s=0,o=e.initialTarget;for(;o&&o!==r;)s++,o=o.parentElement;t.push([s,r])}t.sort((r,s)=>r[0]-s[0]);for(let[,r]of t)r.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,r,s,o,a,l,h,d){this._handle=tt(e,()=>{let c=Date.now(),u=c-r,_=0,p=0,v=!0;s+=K._scrollFriction*u,l+=K._scrollFriction*u,s>0&&(v=!1,_=o*s*u),l>0&&(v=!1,p=h*l*u);let f=this._newGestureEvent(he.CHANGE);f.translationX=_,f.translationY=p,t.forEach(S=>S.dispatchEvent(f)),v||this._inertia(e,t,c,s,o,a+_,l,h,d+p)})}_handleTouchMove(e){let t=Date.now();for(let r=0,s=e.changedTouches.length;r3&&(a.rollingPageX.shift(),a.rollingPageY.shift(),a.rollingTimestamps.shift()),a.rollingPageX.push(o.pageX),a.rollingPageY.push(o.pageY),a.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}};K._scrollFriction=-.005,K._holdDelay=700,K._clearTapCountTime=400,y([Jn],K,"isTouchDevice",1);var Vi=K;var gt=class{constructor(i,e,t,r,s,o,a,l,h){this._renderService=i;this._mouseCoordsService=e;this._mouseStateService=t;this._coreService=r;this._bufferService=s;this._optionsService=o;this._selectionService=a;this._logService=l;this._coreBrowserService=h;this._lastEvent=null;this._wheelPartialScroll=0;this._touchScrollAccumulator=0}bindMouse(i,e,t){let{element:r,document:s}=i,o={mouseup:null,wheel:null,mousedrag:null,mousemove:null},a={target:i,focus:t,requestedEvents:o},l={mouseup:h=>this._handleMouseUp(a,h),wheel:h=>this._handleWheel(a,h),mousedrag:h=>this._handleMouseDrag(a,h),mousemove:h=>this._handleMouseMove(a,h)};this._altMouseCursor=new cs(r,s,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),e(this._altMouseCursor),e(this._mouseStateService.onProtocolChange(h=>{this._handleProtocolChange(a,l,h)})),e(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(r),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,e(E(()=>{o.mouseup&&s.removeEventListener("mouseup",o.mouseup),o.mousedrag&&s.removeEventListener("mousemove",o.mousedrag)})),e(C(r,"mousedown",h=>this._handleMouseDown(a,h))),e(C(r,"wheel",h=>this._handlePassiveWheel(a,h),{passive:!1})),e(Vi.addTarget(i.screenElement)),e(C(i.screenElement,he.START,()=>this._handleTouchStart())),e(C(i.screenElement,he.CHANGE,h=>this._handleTouchChange(a,h)))}_sendEvent(i,e){let t=this._mouseCoordsService.getMouseReportCoords(e,i.target.screenElement);if(!t)return!1;let r,s;switch(e.overrideType||e.type){case"mousemove":s=32,e.buttons===void 0?(r=3,e.button!==void 0&&(r=e.button<3?e.button:3)):r=e.buttons&1?0:e.buttons&4?1:e.buttons&2?2:3;break;case"mouseup":s=0,r=e.button<3?e.button:3;break;case"mousedown":s=1,r=e.button<3?e.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;let a=e.deltaY;if(a===0||this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return!1;s=a<0?0:1,r=4;break;default:return!1}if(s===void 0||r===void 0||r>4||r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!e.altKey)return!1;let o=r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:t.col,row:t.row,x:t.x,y:t.y,button:r,action:s,ctrl:e.ctrlKey,alt:o?!1:e.altKey,shift:e.shiftKey})}_handleMouseUp(i,e){this._sendEvent(i,e),e.buttons||(i.requestedEvents.mouseup&&i.target.document.removeEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.removeEventListener("mousemove",i.requestedEvents.mousedrag))}_handleWheel(i,e){return this._sendEvent(i,e),e.preventDefault(),e.stopPropagation(),!1}_handleMouseDrag(i,e){e.buttons&&this._sendEvent(i,e)}_handleMouseMove(i,e){e.buttons||this._sendEvent(i,e)}_handleMouseDown(i,e){e.preventDefault(),i.focus(),!(!this._mouseStateService.areMouseEventsActive||this._selectionService.shouldForceSelection(e))&&(this._sendEvent(i,e),i.requestedEvents.mouseup&&i.target.document.addEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.addEventListener("mousemove",i.requestedEvents.mousedrag))}_handlePassiveWheel(i,e){if(!i.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;if(!this._bufferService.buffer.hasScrollback){if(e.deltaY===0)return!1;if(this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return e.preventDefault(),e.stopPropagation(),!1;let s="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(s,!0),e.preventDefault(),e.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(i,e){if(e.preventDefault(),e.stopPropagation(),i.requestedEvents.wheel){this._handleTouchScrollAsWheel(i,e);return}if(!this._bufferService.buffer.hasScrollback){this._handleTouchScrollAsKeys(e);return}i.target.handleTouchScroll?.(e.translationY)}_handleTouchScrollAsKeys(i){let e=this._renderService?.dimensions.css.cell.height;if(!e)return;this._touchScrollAccumulator-=i.translationY;let t=Math.trunc(this._touchScrollAccumulator/e);if(t===0)return;this._touchScrollAccumulator-=t*e;let r="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t<0?"A":"B");for(let s=0;s0?1:-1),this._wheelPartialScroll%=1):i.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(s*=this._bufferService.rows),s}_triggerMouseEvent(i){if(i.col<0||i.col>=this._bufferService.cols||i.row<0||i.row>=this._bufferService.rows||i.button===4&&i.action===32||i.button===3&&i.action!==32||i.button!==4&&(i.action===2||i.action===3)||(i.col++,i.row++,i.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,i,this._mouseStateService.isPixelEncoding))||!this._mouseStateService.restrictMouseEvent(i))return!1;let e=this._mouseStateService.encodeMouseEvent(i);return e&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(e):this._coreService.triggerDataEvent(e,!0)),this._lastEvent=i,!0}_explainEvents(i){return{down:!!(i&1),up:!!(i&2),drag:!!(i&4),move:!!(i&8),wheel:!!(i&16)}}_equalEvents(i,e,t){if(t){if(i.x!==e.x||i.y!==e.y)return!1}else if(i.col!==e.col||i.row!==e.row)return!1;return!(i.button!==e.button||i.action!==e.action||i.ctrl!==e.ctrl||i.alt!==e.alt||i.shift!==e.shift)}};gt=y([m(0,V),m(1,Pe),m(2,Me),m(3,Y),m(4,D),m(5,R),m(6,Si),m(7,fe),m(8,G)],gt);var cs=class{constructor(i,e,t){this._element=i;this._document=e;this._isActive=t;this._listeners=new P}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;let i=new pe,e=r=>this.syncFromModifier(r);i.add(C(this._document,"keydown",e)),i.add(C(this._document,"keyup",e)),i.add(C(this._element,"mousemove",e));let t=this._element.ownerDocument?.defaultView;t&&i.add(C(t,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=i}resetClass(){this._updateClass(!1)}syncFromModifier(i){this._isActive()&&this._updateClass(i.getModifierState("Alt"))}_updateClass(i){i?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}};var $i=class{constructor(i,e){this._renderCallback=i;this._coreBrowserService=e;this._refreshCallbacks=[]}dispose(){this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(i){return this._refreshCallbacks.push(i),this._animationFrame??=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()),this._animationFrame}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0){this._runRefreshCallbacks();return}let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(let i of this._refreshCallbacks)i(0);this._refreshCallbacks=[]}};var qi=class{constructor(i){this._tasks=[];this._i=0;this._logService=i}enqueue(i){this._tasks.push(i),this._start()}flush(){for(;this._is){r-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-e))}ms`),this._start();return}r=s}this.clear()}},hs=class extends qi{_requestCallback(i){return setTimeout(()=>i(this._createDeadline(16)))}_cancelCallback(i){clearTimeout(i)}_createDeadline(i){let e=performance.now()+i;return{timeRemaining:()=>Math.max(0,e-performance.now())}}},ds=class extends qi{_requestCallback(i){return requestIdleCallback(i)}_cancelCallback(i){cancelIdleCallback(i)}},It="requestIdleCallback"in globalThis?ds:hs,Xi=class{constructor(i){this._queue=new It(i)}set(i){this._queue.clear(),this._queue.enqueue(i)}flush(){this._queue.flush()}dispose(){this._queue.clear()}};var Ct=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._rowCount=e;this._optionsService=r;this._logService=s;this._charSizeService=o;this._coreService=a;this._coreBrowserService=d;this._renderer=this._register(new P);this._observerDisposable=this._register(new P);this._isPaused=!1;this._needsFullRefresh=!1;this._isNextRenderRedrawOnly=!0;this._needsSelectionRefresh=!1;this._canvasWidth=0;this._canvasHeight=0;this._selectionState={start:void 0,end:void 0,columnSelectMode:!1};this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._onRenderedViewportChange=this._register(new b);this.onRenderedViewportChange=this._onRenderedViewportChange.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onRefreshRequest=this._register(new b);this.onRefreshRequest=this._onRefreshRequest.event;this._pausedResizeTask=this._register(new Xi(this._logService)),this._renderDebouncer=new $i((u,_)=>this._renderRows(u,_),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new us(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register(E(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(h.onResize(()=>this._fullRefresh())),this._register(h.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(h.buffer.y,h.buffer.y,void 0,!0))),this._register(c.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(u=>this._registerIntersectionObserver(u,t)))}get dimensions(){return this._renderer.value.dimensions}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){let r=new e.IntersectionObserver(s=>this._handleIntersectionChange(s[s.length-1]),{threshold:0});this._observerDisposable.value=E(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=r,r.observe(t)}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),!this._isPaused&&!this._charSizeService.hasValidSize&&this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){if(this._renderer.value){if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0}}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(t=>this.refreshRows(t.start,t.end,t.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,r){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=r,this._renderer.value?.handleSelectionChanged(e,t,r)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};Ct=y([m(2,R),m(3,fe),m(4,Be),m(5,Y),m(6,ge),m(7,D),m(8,G),m(9,_e)],Ct);var us=class{constructor(i,e,t){this._coreBrowserService=i;this._coreService=e;this._onTimeout=t;this._start=0;this._end=0;this._isBuffering=!1}bufferRows(i,e){this._isBuffering?(this._start=Math.min(this._start,i),this._end=Math.max(this._end,e)):(this._start=i,this._end=e,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;let i={start:this._start,end:this._end};return this._isBuffering=!1,i}dispose(){this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}};function tn(n,i,e,t){let r=e.buffer.x,s=e.buffer.y;if(!e.buffer.hasScrollback)return ro(r,s,n,i,e,t)+Yi(s,i,e,t)+so(r,s,n,i,e,t);let o;if(s===i)return o=r>n?"D":"C",Yt(Math.abs(r-n),Xt(o,t));o=s>i?"D":"C";let a=Math.abs(s-i),l=io(s>i?n:r,e)+(a-1)*e.cols+1+to(s>i?r:n,e);return Yt(l,Xt(o,t))}function to(n,i){return n-1}function io(n,i){return i.cols-n}function ro(n,i,e,t,r,s){return Yi(i,t,r,s).length===0?"":Yt(sn(n,i,n,i-$e(i,r),!1,r).length,Xt("D",s))}function Yi(n,i,e,t){let r=n-$e(n,e),s=i-$e(i,e),o=Math.abs(r-s)-no(n,i,e);return Yt(o,Xt(rn(n,i),t))}function so(n,i,e,t,r,s){let o;Yi(i,t,r,s).length>0?o=t-$e(t,r):o=i;let a=t,l=oo(n,i,e,t,r,s);return Yt(sn(n,o,e,a,l==="C",r).length,Xt(l,s))}function no(n,i,e){let t=0,r=n-$e(n,e),s=i-$e(i,e);for(let o=0;o=0&&n0?o=t-$e(t,r):o=i,n=e&&oi?"A":"B"}function sn(n,i,e,t,r,s){let o=n,a=i,l="";for(;(o!==e||a!==t)&&a>=0&&as.cols-1?(l+=s.buffer.translateBufferLineToString(a,!1,n,o),o=0,n=0,a++):!r&&o<0&&(l+=s.buffer.translateBufferLineToString(a,!1,0,n+1),o=s.cols-1,n=o,a--);return l+s.buffer.translateBufferLineToString(a,!1,n,o)}function Xt(n,i){let e=i?"O":"[";return"\x1B"+e+n}function Yt(n,i){n=Math.floor(n);let e="";for(let t=0;tthis._bufferService.cols?i%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)-1]:[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[i,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){let i=this.selectionStart[0]+this.selectionStartLength;return i>this._bufferService.cols?[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[Math.max(i,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){let i=this.selectionStart,e=this.selectionEnd;return!i||!e?!1:i[1]>e[1]||i[1]===e[1]&&i[0]>e[0]}handleTrim(i){return this.selectionStart&&(this.selectionStart[1]-=i),this.selectionEnd&&(this.selectionEnd[1]-=i),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):this.selectionStart&&this.selectionStart[1]<0?(this.selectionStart=[0,0],!0):!1}};function fs(n,i){if(n.start.y>n.end.y)throw new Error(`Buffer range end (${n.end.x}, ${n.end.y}) cannot be before start (${n.start.x}, ${n.start.y})`);return i*(n.end.y-n.start.y)+(n.end.x-n.start.x+1)}var ao="\xA0",lo=new RegExp(ao,"g");var Et=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._element=e;this._screenElement=t;this._linkifier=r;this._bufferService=s;this._coreService=o;this._mouseCoordsService=a;this._optionsService=l;this._mouseStateService=h;this._renderService=d;this._coreBrowserService=c;this._dragScrollAmount=0;this._enabled=!0;this._trimListener=this._register(new P);this._workCell=new F;this._mouseDownTimeStamp=0;this._oldHasSelection=!1;this._oldSelectionStart=void 0;this._oldSelectionEnd=void 0;this._onLinuxMouseSelection=this._register(new b);this.onLinuxMouseSelection=this._onLinuxMouseSelection.event;this._onRedrawRequest=this._register(new b);this.onRequestRedraw=this._onRedrawRequest.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onRequestScrollLines=this._register(new b);this.onRequestScrollLines=this._onRequestScrollLines.event;this._mouseMoveListener=u=>this._handleMouseMove(u),this._mouseUpListener=u=>this._handleMouseUp(u),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(u=>this._handleTrim(u)),this._register(this._bufferService.buffers.onBufferActivate(u=>this._handleBufferActivate(u))),this.enable(),this._model=new ji(this._bufferService),this._activeSelectionMode=0,this._register(E(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(u=>{u.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!e||!t?!1:e[0]!==t[0]||e[1]!==t[1]}get selectionText(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";let r=this._bufferService.buffer,s=[];if(this._activeSelectionMode===3){if(e[0]===t[0])return"";let a=e[0]a.replace(lo," ")).join(Ue?`\r +`)})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._renderService.onRender(()=>{this._needsSyncOnRender&&(this._needsSyncOnRender=!1,this._sync())})),this._register(this._scrollableElement.onScroll(u=>this._handleScroll(u)))}scrollLines(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){let e=this._optionsService.rawOptions.scrollbar?.showScrollbar??!0,t=this._optionsService.rawOptions.scrollbar?.showArrows??!1,r=e?this._optionsService.rawOptions.scrollbar?.width??14:0;return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,vertical:e?1:2,verticalScrollbarSize:r,verticalHasArrows:t}}queueSync(e){e!==void 0&&(this._latestYDisp=e),this._queuedAnimationFrame===void 0&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){if(!(!this._renderService||this._isSyncing)){if(this._coreService.decPrivateModes.synchronizedOutput){this._needsSyncOnRender=!0;return}this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1}}_handleScroll(e){if(!this._renderService||this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;let t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),r=t-this._bufferService.buffer.ydisp;r!==0&&(this._latestYDisp=t,this._onRequestScrollLines.fire(r)),this._isHandlingScroll=!1}handleTouchScroll(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({scrollTop:t.scrollTop-e})}};dt=y([m(2,D),m(3,G),m(4,Y),m(5,Me),m(6,_e),m(7,R),m(8,V)],dt);var ut=class extends g{constructor(e,t,r,s,o){super();this._screenElement=e;this._bufferService=t;this._coreBrowserService=r;this._decorationService=s;this._renderService=o;this._decorationElements=new Map;this._altBufferIsActive=!1;this._dimensionsChanged=!1;this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(a=>this._removeDecoration(a))),this._register(E(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(let e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){let t=this._coreBrowserService.mainDocument.createElement("div");t.classList.add("xterm-decoration"),t.classList.toggle("xterm-decoration-top-layer",e?.options?.layer==="top"),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,t.style.top=`${(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height}px`,t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;let r=e.options.x??0;return r&&r>this._bufferService.cols&&(t.style.display="none"),this._refreshXPosition(e,t),t}_refreshStyle(e){let t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let r=this._decorationElements.get(e);r||(r=this._createElement(e),e.element=r,this._decorationElements.set(e,r),this._container.appendChild(r),e.onDispose(()=>{this._decorationElements.delete(e),r.remove()})),r.style.display=this._altBufferIsActive?"none":"block",this._altBufferIsActive||(r.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,r.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,r.style.top=`${t*this._renderService.dimensions.css.cell.height}px`,r.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(r)}}_refreshXPosition(e,t=e.element){if(!t)return;let r=e.options.x??0;(e.options.anchor||"left")==="right"?t.style.right=r?`${r*this._renderService.dimensions.css.cell.width}px`:"":t.style.left=r?`${r*this._renderService.dimensions.css.cell.width}px`:""}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};ut=y([m(1,D),m(2,G),m(3,ge),m(4,V)],ut);var Pi=class{constructor(){this._zones=[];this._zonePool=[];this._zonePoolIndex=0;this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(i){if(i.options.overviewRulerOptions){for(let e of this._zones)if(e.color===i.options.overviewRulerOptions.color&&e.position===i.options.overviewRulerOptions.position){if(this._lineIntersectsZone(e,i.marker.line))return;if(this._lineAdjacentToZone(e,i.marker.line,i.options.overviewRulerOptions.position)){this._addLineToZone(e,i.marker.line);return}}if(this._zonePoolIndex=i.startBufferLine&&e<=i.endBufferLine}_lineAdjacentToZone(i,e,t){return e>=i.startBufferLine-this._linePadding[t||"full"]&&e<=i.endBufferLine+this._linePadding[t||"full"]}_addLineToZone(i,e){i.startBufferLine=Math.min(i.startBufferLine,e),i.endBufferLine=Math.max(i.endBufferLine,e)}};var Ce={full:0,left:0,center:0,right:0},Fe={full:0,left:0,center:0,right:0},$t={full:0,left:0,center:0,right:0},ze=class extends g{constructor(e,t,r,s,o,a,l,h){super();this._viewportElement=e;this._screenElement=t;this._bufferService=r;this._decorationService=s;this._renderService=o;this._optionsService=a;this._themeService=l;this._coreBrowserService=h;this._colorZoneStore=new Pi;this._shouldUpdateDimensions=!0;this._shouldUpdateAnchor=!0;this._lastKnownBufferLength=0;this._canvas=this._coreBrowserService.mainDocument.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register(E(()=>this._canvas?.remove()));let d=this._canvas.getContext("2d");if(d)this._ctx=d;else throw new Error("Ctx cannot be null");this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onDimensionsChange(()=>this._queueRefresh(!0))),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange("scrollbar",()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._register(E(()=>{this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)})),this._queueRefresh(!0)}get _width(){let e=this._optionsService.rawOptions.scrollbar;return e?.showScrollbar??!0?e?.width??0:0}_refreshDrawConstants(){let e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);Fe.full=this._canvas.width,Fe.left=e,Fe.center=t,Fe.right=e,this._refreshDrawHeightConstants(),$t.full=1,$t.left=1,$t.center=1+Fe.left,$t.right=1+Fe.left+Fe.center}_refreshDrawHeightConstants(){Ce.full=Math.round(2*this._coreBrowserService.dpr);let e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);Ce.left=t,Ce.center=t,Ce.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*Ce.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;let e=this._renderService.dimensions.css.canvas.height,t=this._renderService.dimensions.device.canvas.height;this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${e}px`,this._canvas.height=t,this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){if(this._store.isDisposed||!this._renderService.hasRenderer())return;this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(let t of this._decorationService.decorations)this._colorZoneStore.addDecoration(t);this._ctx.lineWidth=1,this._renderRulerOutline();let e=this._colorZoneStore.zones;for(let t of e)t.position!=="full"&&this._renderColorZone(t);for(let t of e)t.position==="full"&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect($t[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-Ce[e.position||"full"]/2),Fe[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+Ce[e.position||"full"]))}_queueRefresh(e,t){this._store.isDisposed||(this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._store.isDisposed||this._refreshDecorations(),this._animationFrame=void 0})))}};ze=y([m(2,D),m(3,ge),m(4,V),m(5,R),m(6,_e),m(7,G)],ze);var ft=class{constructor(i,e,t,r,s,o){this._textarea=i;this._compositionView=e;this._bufferService=t;this._optionsService=r;this._coreService=s;this._renderService=o;this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._compositionSuffix="",this._dataAlreadySent="",this._pendingKeypressData=""}get isComposing(){return this._isComposing}compositionstart(){this._isComposing=!0;let i=this._textarea.selectionStart??this._textarea.value.length,e=this._textarea.selectionEnd??i;this._compositionPosition.start=Math.min(i,e),this._compositionPosition.end=Math.max(i,e),this._compositionSuffix=this._textarea.value.substring(this._compositionPosition.end),this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(i){this._compositionView.textContent=`\u200E${i.data}\u200E`,this.updateCompositionElements(),setTimeout(()=>{let e=this._textarea.selectionEnd??this._textarea.value.length;this._compositionPosition.end=Math.max(this._compositionPosition.start,e)},0)}compositionend(){this._finalizeComposition(!0)}keydown(i){if(this._isComposing||this._isSendingComposition){if(i.keyCode===20||i.keyCode===229||i.keyCode===16||i.keyCode===17||i.keyCode===18)return!1;this._finalizeComposition(!1)}return i.keyCode===229?(this._handleAnyTextareaChanges(),!1):!0}keypress(i){return this._isSendingComposition?(this._pendingKeypressData+=i,!0):!1}_finalizeComposition(i){if(this._compositionView.classList.remove("active"),this._isComposing=!1,i){let e={start:this._compositionPosition.start,end:this._compositionPosition.end},t=this._compositionSuffix;this._pendingKeypressData="",this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){this._isSendingComposition=!1;let r;if(e.start+=this._dataAlreadySent.length,this._isComposing)r=this._textarea.value.substring(e.start,this._compositionPosition.start);else{let s=this._textarea.value,o=t.length>0&&s.endsWith(t)?s.length-t.length:s.length;r=s.substring(e.start,Math.max(e.start,o))}this._sendCompositionInput(r)}},0)}else{this._isSendingComposition=!1;let e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._sendCompositionInput(e)}}_sendCompositionInput(i){let e=this._pendingKeypressData;if(!i.includes(e))if(e.includes(i))i=e;else{let t=Math.min(i.length,e.length);for(;t>0&&!i.endsWith(e.substring(0,t));)t--;let r=Math.min(i.length,e.length);for(;r>0&&!e.endsWith(i.substring(0,r));)r--;i=t>r?i+e.substring(t):e+i.substring(r)}this._pendingKeypressData="",i.length>0&&this._coreService.triggerDataEvent(i,!0)}_handleAnyTextareaChanges(){if(this._textareaChangeTimer)return;let i=this._textarea.value;this._textareaChangeTimer=window.setTimeout(()=>{if(this._textareaChangeTimer=void 0,!this._isComposing){let e=this._textarea.value,t=e.replace(i,"");this._dataAlreadySent=t,e.length>i.length?this._coreService.triggerDataEvent(t,!0):e.lengththis.updateCompositionElements(!0),0)}}};ft=y([m(2,D),m(3,R),m(4,Y),m(5,V)],ft);var J=0,Q=0,ee=0,W=0,ts={css:"#00000000",rgba:0},O;(t=>{function n(r,s,o,a){return a!==void 0?`#${Ve(r)}${Ve(s)}${Ve(o)}${Ve(a)}`:`#${Ve(r)}${Ve(s)}${Ve(o)}`}t.toCss=n;function i(r,s,o,a=255){return(r<<24|s<<16|o<<8|a)>>>0}t.toRgba=i;function e(r,s,o,a){return{css:t.toCss(r,s,o,a),rgba:t.toRgba(r,s,o,a)}}t.toColor=e})(O||={});var k;(a=>{function n(l,h){if(W=(h.rgba&255)/255,W===1)return{css:h.css,rgba:h.rgba};let d=h.rgba>>24&255,c=h.rgba>>16&255,u=h.rgba>>8&255,_=l.rgba>>24&255,p=l.rgba>>16&255,v=l.rgba>>8&255;J=_+Math.round((d-_)*W),Q=p+Math.round((c-p)*W),ee=v+Math.round((u-v)*W);let f=O.toCss(J,Q,ee),S=O.toRgba(J,Q,ee);return{css:f,rgba:S}}a.blend=n;function i(l){return(l.rgba&255)===255}a.isOpaque=i;function e(l,h,d){let c=Oi.ensureContrastRatio(l.rgba,h.rgba,d);if(c)return O.toColor(c>>24&255,c>>16&255,c>>8&255)}a.ensureContrastRatio=e;function t(l){let h=(l.rgba|255)>>>0;return[J,Q,ee]=Oi.toChannels(h),{css:O.toCss(J,Q,ee),rgba:h}}a.opaque=t;function r(l,h){return W=Math.round(h*255),[J,Q,ee]=Oi.toChannels(l.rgba),{css:O.toCss(J,Q,ee,W),rgba:O.toRgba(J,Q,ee,W)}}a.opacity=r;function s(l,h){return W=l.rgba&255,r(l,W*h/255)}a.multiplyOpacity=s;function o(l){return[l.rgba>>24&255,l.rgba>>16&255,l.rgba>>8&255]}a.toColorRGB=o})(k||={});var B;(t=>{let n,i;try{let r=document.createElement("canvas");r.width=1,r.height=1;let s=r.getContext("2d",{willReadFrequently:!0});s&&(n=s,n.globalCompositeOperation="copy",i=n.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),O.toColor(J,Q,ee);case 5:return J=parseInt(r.slice(1,2).repeat(2),16),Q=parseInt(r.slice(2,3).repeat(2),16),ee=parseInt(r.slice(3,4).repeat(2),16),W=parseInt(r.slice(4,5).repeat(2),16),O.toColor(J,Q,ee,W);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let s=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return J=parseInt(s[1],10),Q=parseInt(s[2],10),ee=parseInt(s[3],10),W=Math.round((s[5]===void 0?1:parseFloat(s[5]))*255),O.toColor(J,Q,ee,W);if(r==="transparent")return{css:"transparent",rgba:0};if(!n||!i)throw new Error("css.toColor: Unsupported css format");if(n.fillStyle=i,n.fillStyle=r,typeof n.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(n.fillRect(0,0,1,1),[J,Q,ee,W]=n.getImageData(0,0,1,1).data,W!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:O.toRgba(J,Q,ee,W),css:r}}t.toColor=e})(B||={});var Z;(e=>{function n(t){return i(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=n;function i(t,r,s){let o=t/255,a=r/255,l=s/255,h=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4),d=a<=.03928?a/12.92:Math.pow((a+.055)/1.055,2.4),c=l<=.03928?l/12.92:Math.pow((l+.055)/1.055,2.4);return h*.2126+d*.7152+c*.0722}e.relativeLuminance2=i})(Z||={});var Oi;(s=>{function n(o,a){if(W=(a&255)/255,W===1)return a;let l=a>>24&255,h=a>>16&255,d=a>>8&255,c=o>>24&255,u=o>>16&255,_=o>>8&255;return J=c+Math.round((l-c)*W),Q=u+Math.round((h-u)*W),ee=_+Math.round((d-_)*W),O.toRgba(J,Q,ee)}s.blend=n;function i(o,a,l){let h=Z.relativeLuminance(o>>8),d=Z.relativeLuminance(a>>8);if(Te(h,d)>8));if(v>8));return v>S?p:f}return p}let u=t(o,a,l),_=Te(h,Z.relativeLuminance(u>>8));if(_>8));return _>v?u:p}return u}}s.ensureContrastRatio=i;function e(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v0||_>0||p>0);)u-=Math.max(0,Math.ceil(u*.1)),_-=Math.max(0,Math.ceil(_*.1)),p-=Math.max(0,Math.ceil(p*.1)),v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));return(u<<24|_<<16|p<<8|255)>>>0}s.reduceLuminance=e;function t(o,a,l){let h=o>>24&255,d=o>>16&255,c=o>>8&255,u=a>>24&255,_=a>>16&255,p=a>>8&255,v=Te(Z.relativeLuminance2(u,_,p),Z.relativeLuminance2(h,d,c));for(;v>>0}s.increaseLuminance=t;function r(o){return[o>>24&255,o>>16&255,o>>8&255,o&255]}s.toChannels=r})(Oi||={});function Ve(n){let i=n.toString(16);return i.length<2?"0"+i:i}function Te(n,i){return n1){let u=this._getJoinedRanges(r,l,a,e,o);for(let _=0;_1){let c=this._getJoinedRanges(r,l,a,e,o);for(let u=0;u=ks,Lr=ae,x=this._workCell;if(v.length>0&&ae===v[0][0]&&je){let A=v.shift(),Pr=this._isCellInSelection(A[0],e);for(T=A[0]+1;T=A[1],je?(fi=!0,x=new Ni(this._workCell,i.translateToString(!0,A[0],A[1]),A[1]-A[0]),Lr=A[1]-1,Rr=x.getWidth()):ks=A[1]}let Nt=this._isCellInSelection(ae,e),Ar=t&&ae===o,kr=An&&ae>=c&&ae<=u;_&&x.isBlink()&&(_.hasBlinkingCells=!0),!l&&x.isBlink()&&N.push("xterm-blink-hidden");let Mr=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{Mr=!0});let _i=x.getChars()||" ";if(_i===" "&&(x.isUnderline()||x.isOverline())&&(_i="\xA0"),Ot=Rr*h-d.get(_i,x.isBold(),x.isItalic()),!I)I=this._document.createElement("span");else if(w&&(Nt&&ui||!Nt&&!ui&&x.bg===te)&&(Nt&&ui&&f.selectionForeground||x.fg===Ds)&&x.extended.ext===Rs&&kr===Ls&&Ot===As&&!Ar&&!fi&&!Mr&&je){x.isInvisible()?L+=" ":L+=_i,w++;continue}else w&&(I.textContent=L),I=this._document.createElement("span"),w=0,L="";if(te=x.bg,Ds=x.fg,Rs=x.extended.ext,Ls=kr,As=Ot,ui=Nt,fi&&o>=ae&&o<=Lr&&(o=ae),!this._coreService.isCursorHidden&&Ar&&this._coreService.isCursorInitialized){if(N.push("xterm-cursor"),this._coreBrowserService.isFocused)a&&N.push("xterm-cursor-blink"),N.push(r==="bar"?"xterm-cursor-bar":r==="underline"?"xterm-cursor-underline":"xterm-cursor-block");else if(s)switch(s){case"outline":N.push("xterm-cursor-outline");break;case"block":N.push("xterm-cursor-block");break;case"bar":N.push("xterm-cursor-bar");break;case"underline":N.push("xterm-cursor-underline");break;default:break}}if(x.isBold()&&N.push("xterm-bold"),x.isItalic()&&N.push("xterm-italic"),x.isDim()&&N.push("xterm-dim"),x.isInvisible()?L=" ":L=x.getChars()||" ",x.isUnderline()&&(N.push(`xterm-underline-${x.extended.underlineStyle}`),L===" "&&(L="\xA0"),!x.isUnderlineColorDefault()))if(x.isUnderlineColorRGB())I.style.textDecorationColor=`rgb(${ue.toColorRGB(x.getUnderlineColor()).join(",")})`;else{let A=x.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&x.isBold()&&A<8&&(A+=8),I.style.textDecorationColor=f.ansi[A].css}x.isOverline()&&(N.push("xterm-overline"),L===" "&&(L="\xA0")),x.isStrikethrough()&&N.push("xterm-strikethrough"),kr&&(I.style.textDecoration="underline");let de=x.getFgColor(),Ft=x.getFgColorMode(),Se=x.getBgColor(),Ht=x.getBgColorMode(),Br=!!x.isInverse();if(Br){let A=de;de=Se,Se=A;let Pr=Ft;Ft=Ht,Ht=Pr}let Le,pi,Wt=!1;this._decorationService.forEachDecorationAtCell(ae,e,void 0,A=>{A.options.layer!=="top"&&Wt||(A.backgroundColorRGB&&(Ht=50331648,Se=A.backgroundColorRGB.rgba>>8&16777215,Le=A.backgroundColorRGB),A.foregroundColorRGB&&(Ft=50331648,de=A.foregroundColorRGB.rgba>>8&16777215,pi=A.foregroundColorRGB),Wt=A.options.layer==="top")}),!Wt&&Nt&&(Le=this._coreBrowserService.isFocused?f.selectionBackgroundOpaque:f.selectionInactiveBackgroundOpaque,Se=Le.rgba>>8&16777215,Ht=50331648,Wt=!0,f.selectionForeground&&(Ft=50331648,de=f.selectionForeground.rgba>>8&16777215,pi=f.selectionForeground)),Wt&&N.push("xterm-decoration-top");let Ae;switch(Ht){case 16777216:case 33554432:Ae=f.ansi[Se],N.push(`xterm-bg-${Se}`);break;case 50331648:Ae=O.toColor(Se>>16,Se>>8&255,Se&255),this._addStyle(I,`background-color:#${(Se>>>0).toString(16).padStart(6,"0")}`);break;case 0:default:Br?(Ae=f.foreground,N.push(`xterm-bg-${257}`)):Ae=f.background}switch(Le||x.isDim()&&(Le=k.multiplyOpacity(Ae,.5)),Ft){case 16777216:case 33554432:x.isBold()&&de<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(de+=8),this._applyMinimumContrast(I,Ae,f.ansi[de],x,Le,void 0)||N.push(`xterm-fg-${de}`);break;case 50331648:let A=O.toColor(de>>16&255,de>>8&255,de&255);this._applyMinimumContrast(I,Ae,A,x,Le,pi)||this._addStyle(I,`color:#${de.toString(16).padStart(6,"0")}`);break;case 0:default:this._applyMinimumContrast(I,Ae,f.foreground,x,Le,pi)||Br&&N.push(`xterm-fg-${257}`)}N.length&&(I.className=N.join(" "),N.length=0),!Ar&&!fi&&!Mr&&je?w++:I.textContent=L,Ot!==this.defaultSpacing&&(I.style.letterSpacing=`${Ot}px`),p.push(I),ae=Lr}return I&&w&&(I.textContent=L),p}_applyMinimumContrast(i,e,t,r,s,o){if(this._optionsService.rawOptions.minimumContrastRatio===1||js(r.getCode()))return!1;let a=this._getContrastCache(r),l;if(!s&&!o&&(l=a.getColor(e.rgba,t.rgba)),l===void 0){let h=this._optionsService.rawOptions.minimumContrastRatio/(r.isDim()?2:1);l=k.ensureContrastRatio(s??e,o??t,h),a.setColor((s??e).rgba,(o??t).rgba,l??null)}return l?(this._addStyle(i,`color:${l.css}`),!0):!1}_getContrastCache(i){return i.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(i,e){i.setAttribute("style",`${i.getAttribute("style")||""}${e};`)}_isCellInSelection(i,e){let t=this._selectionStart,r=this._selectionEnd;return!t||!r?!1:this._columnSelectMode?t[0]<=r[0]?i>=t[0]&&e>=t[1]&&i=t[1]&&i>=r[0]&&e<=r[1]:e>t[1]&&e=t[0]&&i=t[0]}};_t=y([m(1,gi),m(2,R),m(3,G),m(4,Y),m(5,ge),m(6,_e)],_t);var Hi=class{constructor(i=()=>new rs){this._flat=new Float32Array(256);this._font="";this._fontSize=0;this._weight="normal";this._weightBold="bold";this._canvasElements=[];this._canvasElements=[i(),i(),i(),i()],this.clear()}dispose(){this._canvasElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(i,e,t,r){i===this._font&&e===this._fontSize&&t===this._weight&&r===this._weightBold||(this._font=i,this._fontSize=e,this._weight=t,this._weightBold=r,this._canvasElements[0].setFont(i,e,t,!1),this._canvasElements[1].setFont(i,e,r,!1),this._canvasElements[2].setFont(i,e,t,!0),this._canvasElements[3].setFont(i,e,r,!0),this.clear())}get(i,e,t){let r;if(!e&&!t&&i.length===1&&(r=i.charCodeAt(0))<256){if(this._flat[r]!==-9999)return this._flat[r];let a=this._measure(i,0);return a>0&&(this._flat[r]=a),a}let s=i;e&&(s+="B"),t&&(s+="I");let o=this._holey.get(s);if(o===void 0){let a=0;e&&(a|=1),t&&(a|=2),o=this._measure(i,a),o>0&&this._holey.set(s,o)}return o}_measure(i,e){return this._canvasElements[e].measure(i)}},rs=class{constructor(){typeof OffscreenCanvas<"u"?(this._canvas=new OffscreenCanvas(1,1),this._ctx=is(this._canvas.getContext("2d"))):(this._canvas=document.createElement("canvas"),this._canvas.width=1,this._canvas.height=1,this._ctx=is(this._canvas.getContext("2d")))}setFont(i,e,t,r){let s=r?"italic":"";this._ctx.font=`${s} ${t} ${e}px ${i}`.trim()}measure(i){return this._ctx.measureText(i).width}};var ss=class{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(i,e,t,r=!1){if(this.selectionStart=e,this.selectionEnd=t,!e||!t||e[0]===t[0]&&e[1]===t[1]){this.clear();return}let s=i.buffers.active.ydisp,o=e[1]-s,a=t[1]-s,l=Math.max(o,0),h=Math.min(a,i.rows-1);if(l>=i.rows||h<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=r,this.viewportStartRow=o,this.viewportEndRow=a,this.viewportCappedStartRow=l,this.viewportCappedEndRow=h,this.startCol=e[0],this.endCol=t[0]}isCellSelected(i,e,t){return this.hasSelection?(t-=i.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?e>=this.startCol&&t>=this.viewportCappedStartRow&&e=this.viewportCappedStartRow&&e>=this.endCol&&t<=this.viewportCappedEndRow:t>this.viewportStartRow&&t=this.startCol&&e=this.startCol):!1}};function Js(){return new ss}var Wi=class extends g{constructor(e,t,r){super();this._renderCallback=e;this._coreBrowserService=t;this._optionsService=r;this._intervalDuration=0;this._blinkOn=!0;this._needsBlinkInViewport=!1;this._isViewportVisible=!0;this._register(this._optionsService.onSpecificOptionChange("blinkIntervalDuration",s=>{this.setIntervalDuration(s)})),this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration),this._register(E(()=>this._clearInterval()))}get isBlinkOn(){return this._blinkOn}get isEnabled(){return this._intervalDuration>0}setNeedsBlinkInViewport(e){this._needsBlinkInViewport!==e&&(this._needsBlinkInViewport=e,this._updateIntervalState())}setViewportVisible(e){this._isViewportVisible!==e&&(this._isViewportVisible=e,this._updateIntervalState())}setIntervalDuration(e){e!==this._intervalDuration&&(this._intervalDuration=e,this._clearInterval(),this._updateIntervalState())}_updateIntervalState(){if(this._intervalDuration>0&&this._needsBlinkInViewport&&this._isViewportVisible){if(this._interval!==void 0)return;let t=this._blinkOn;this._blinkOn=!0,this._interval=this._coreBrowserService.window.setInterval(()=>{this._blinkOn=!this._blinkOn,this._renderCallback()},this._intervalDuration),t||this._renderCallback();return}this._clearInterval(),this._blinkOn||(this._blinkOn=!0,this._renderCallback())}_clearInterval(){this._interval!==void 0&&(this._coreBrowserService.window.clearInterval(this._interval),this._interval=void 0)}};var Zn=1,mt=class extends g{constructor(e,t,r,s,o,a,l,h,d,c,u,_,p,v){super();this._terminal=e;this._document=t;this._element=r;this._screenElement=s;this._viewportElement=o;this._helperContainer=a;this._linkifier2=l;this._charSizeService=d;this._optionsService=c;this._bufferService=u;this._coreService=_;this._coreBrowserService=p;this._themeService=v;this._terminalClass=Zn++;this._rowElements=[];this._selectionRenderModel=Js();this._lastSelectionColumnMode=!1;this._rowHasBlinkingCells=[];this._rowHasBlinkingCellsCount=0;this._onRequestRedraw=this._register(new b);this.onRequestRedraw=this._onRequestRedraw.event;this._rowContainer=this._document.createElement("div"),this._rowContainer.classList.add("xterm-rows"),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement("div"),this._selectionContainer.classList.add("xterm-selection"),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=Zs(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(f=>this._injectCss(f))),this._injectCss(this._themeService.colors),this._rowFactory=h.createInstance(_t,document),this._element.classList.add("xterm-dom-renderer-owner-"+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(f=>this._handleLinkHover(f))),this._register(this._linkifier2.onHideLinkUnderline(f=>this._handleLinkLeave(f))),this._cursorBlinkStateManager=new ns(this._rowContainer,this._coreBrowserService),this._register(C(this._document,"mousedown",()=>this._cursorBlinkStateManager.restartBlinkAnimation())),this._register(E(()=>this._cursorBlinkStateManager.dispose())),this._textBlinkStateManager=this._register(new Wi(()=>this._onRequestRedraw.fire({start:0,end:this._bufferService.rows-1}),this._coreBrowserService,this._optionsService)),this._register(E(()=>{this._element.classList.remove("xterm-dom-renderer-owner-"+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new Hi,this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){let e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(let r of this._rowElements)r.style.width=`${this.dimensions.css.canvas.width}px`,r.style.height=`${this.dimensions.css.cell.height}px`,r.style.lineHeight=`${this.dimensions.css.cell.height}px`,r.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));let t=`${this._terminalSelector} .xterm-rows span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .xterm-rows { pointer-events: none; color: ${e.foreground.css};}`;t+=`${this._terminalSelector} .xterm-rows, ${this._terminalSelector} .xterm-rows span { font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`,t+=`${this._terminalSelector} .xterm-rows .xterm-dim { color: ${k.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}${this._terminalSelector} span.xterm-blink-hidden { visibility: hidden;}`;let r=`blink_underline_${this._terminalClass}`,s=`blink_bar_${this._terminalClass}`,o=`blink_block_${this._terminalClass}`;t+=`@keyframes ${r} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${s} { 50% { box-shadow: none; }}`,t+=`@keyframes ${o} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${s} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-focus .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${o} 1s step-end infinite;}${this._terminalSelector} .xterm-rows.xterm-cursor-blink-idle .xterm-cursor.xterm-cursor-blink { animation: none !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .xterm-rows .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .xterm-selection { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .xterm-selection div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .xterm-selection div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(let[a,l]of e.ansi.entries())t+=`${this._terminalSelector} .xterm-fg-${a} { color: ${l.css}; }${this._terminalSelector} .xterm-fg-${a}.xterm-dim { color: ${k.multiplyOpacity(l,.5).css}; }${this._terminalSelector} .xterm-bg-${a} { background-color: ${l.css}; }`;t+=`${this._terminalSelector} .xterm-fg-${257} { color: ${k.opaque(e.background).css}; }${this._terminalSelector} .xterm-fg-${257}.xterm-dim { color: ${k.multiplyOpacity(k.opaque(e.background),.5).css}; }${this._terminalSelector} .xterm-bg-${257} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){let e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let r=this._rowElements.length;r<=t;r++){let s=this._document.createElement("div");this._rowContainer.appendChild(s),this._rowElements.push(s),this._rowHasBlinkingCells.push(!1)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop()),this._rowHasBlinkingCells.pop()&&this._rowHasBlinkingCellsCount--}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove("xterm-focus"),this._cursorBlinkStateManager.pause(),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add("xterm-focus"),this._cursorBlinkStateManager.resume(),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleViewportVisibilityChange(e){this._textBlinkStateManager.setViewportVisible(e)}handleSelectionChanged(e,t,r){let s=this._bufferService.rows;this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,r);let o=0,a=-1;this._lastSelectionStart&&this._lastSelectionEnd&&(this._selectionRenderModel.update(this._terminal,this._lastSelectionStart,this._lastSelectionEnd,this._lastSelectionColumnMode),this._selectionRenderModel.hasSelection&&(o=this._selectionRenderModel.viewportCappedStartRow,a=this._selectionRenderModel.viewportCappedEndRow));let l=0,h=-1;if(!e||!t)return;if(this._selectionRenderModel.update(this._terminal,e,t,r),this._selectionRenderModel.hasSelection){let u=this._selectionRenderModel.viewportStartRow,_=this._selectionRenderModel.viewportEndRow,p=this._selectionRenderModel.viewportCappedStartRow,v=this._selectionRenderModel.viewportCappedEndRow;l=p,h=v;let f=this._document.createDocumentFragment();if(r){let S=e[0]>t[0];f.appendChild(this._createSelectionElement(p,S?t[0]:e[0],S?e[0]:t[0],v-p+1))}else{let S=u===p?e[0]:0,I=p===_?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(p,S,I));let w=v-p-1;if(f.appendChild(this._createSelectionElement(p+1,0,this._bufferService.cols,w)),p!==v){let L=_===v?t[0]:this._bufferService.cols;f.appendChild(this._createSelectionElement(v,0,L))}}this._selectionContainer.appendChild(f)}let d=Math.min(o,l),c=Math.max(a,h);if(c>=0){d=Math.max(d,0),c=Math.min(c,s-1);let _=this._bufferService.buffer.y;this._selectionRenderModel.hasSelection&&_>=0&&_this.dimensions.css.canvas.width&&(l=this.dimensions.css.canvas.width-a),o.style.height=`${s*this.dimensions.css.cell.height}px`,o.style.top=`${e*this.dimensions.css.cell.height}px`,o.style.left=`${a}px`,o.style.width=`${l}px`,o}handleCursorMove(){this._cursorBlinkStateManager.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(let e of this._rowElements)e.replaceChildren();this._rowHasBlinkingCellsCount>0&&(this._rowHasBlinkingCells.fill(!1),this._rowHasBlinkingCellsCount=0,this._textBlinkStateManager.setNeedsBlinkInViewport(!1))}renderRows(e,t){let r=this._bufferService.buffer,s=r.ybase+r.y,o=Math.min(r.x,this._bufferService.cols-1),a=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,l=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,h=this._optionsService.rawOptions.cursorInactiveStyle,d={hasBlinkingCells:!1};for(let c=e;c<=t;c++){let u=c+r.ydisp,_=this._rowElements[c];if(!_)continue;let p=r.lines.get(u);if(!p){_.replaceChildren(),this._setRowBlinkState(c,!1);continue}_.replaceChildren(...this._rowFactory.createRow(p,u,u===s,l,h,o,a,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,-1,-1,d)),this._setRowBlinkState(c,d.hasBlinkingCells)}this._updateTextBlinkState()}get _terminalSelector(){return`.xterm-dom-renderer-owner-${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,r,s,o,a){r<0&&(e=0),s<0&&(t=0);let l=this._bufferService.rows-1;r=Math.max(Math.min(r,l),0),s=Math.max(Math.min(s,l),0),o=Math.min(o,this._bufferService.cols);let h=this._bufferService.buffer,d=h.ybase+h.y,c=Math.min(h.x,o-1),u=this._optionsService.rawOptions.cursorBlink,_=this._optionsService.rawOptions.cursorStyle,p=this._optionsService.rawOptions.cursorInactiveStyle,v={hasBlinkingCells:!1};for(let f=r;f<=s;++f){let S=f+h.ydisp,I=this._rowElements[f];if(!I)continue;let w=h.lines.get(S);if(!w){I.replaceChildren(),this._setRowBlinkState(f,!1);continue}I.replaceChildren(...this._rowFactory.createRow(w,S,S===d,_,p,c,u,this._textBlinkStateManager.isBlinkOn,this.dimensions.css.cell.width,this._widthCache,a?f===r?e:0:-1,a?(f===s?t:o)-1:-1,v)),this._setRowBlinkState(f,v.hasBlinkingCells)}this._updateTextBlinkState()}_setRowBlinkState(e,t){this._rowHasBlinkingCells[e]!==t&&(this._rowHasBlinkingCells[e]=t,this._rowHasBlinkingCellsCount+=t?1:-1)}_updateTextBlinkState(){this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount>0)}};mt=y([m(7,Qe),m(8,Be),m(9,R),m(10,D),m(11,Y),m(12,G),m(13,_e)],mt);var ns=class{constructor(i,e){this._rowContainer=i;this._coreBrowserService=e;this._isIdlePaused=!1;this._coreBrowserService.isFocused&&this._resetIdleTimer()}dispose(){this._clearIdleTimer()}restartBlinkAnimation(){this._isIdlePaused&&this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}pause(){this._isIdlePaused=!1,this._clearIdleTimer()}resume(){this._isIdlePaused=!1,this._rowContainer.classList.remove("xterm-cursor-blink-idle"),this._resetIdleTimer()}_resetIdleTimer(){this._isIdlePaused=!1,this._clearIdleTimer(),this._idleTimeout=this._coreBrowserService.window.setTimeout(()=>{this._stopBlinkingDueToIdle()},3e5)}_clearIdleTimer(){this._idleTimeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._idleTimeout),this._idleTimeout=void 0)}_stopBlinkingDueToIdle(){this._rowContainer.classList.add("xterm-cursor-blink-idle"),this._isIdlePaused=!0,this._idleTimeout=void 0}};var bt=class extends g{constructor(e,t,r){super();this._optionsService=r;this.width=0;this.height=0;this._onCharSizeChange=this._register(new b);this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new as(this._optionsService))}catch{this._measureStrategy=this._register(new os(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],()=>this.measure()))}get hasValidSize(){return this.width>0&&this.height>0}measure(){let e=this._measureStrategy.measure();(e.width!==this.width||e.height!==this.height)&&(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};bt=y([m(2,R)],bt);var Ui=class extends g{constructor(){super(...arguments);this._result={width:0,height:0}}_validateAndSet(e,t){e!==void 0&&e>0&&t!==void 0&&t>0&&(this._result.width=e,this._result.height=t)}},os=class extends Ui{constructor(e,t,r){super();this._document=e;this._parentElement=t;this._optionsService=r;this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}},as=class extends Ui{constructor(e){super();this._optionsService=e;this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext("2d");let t=this._ctx.measureText("W");if(!("width"in t&&"fontBoundingBoxAscent"in t&&"fontBoundingBoxDescent"in t))throw new Error("Required font metrics not supported")}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;let e=this._ctx.measureText("W");return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}};var Ki=class extends g{constructor(e,t,r){super();this._textarea=e;this._window=t;this.mainDocument=r;this._isFocused=!1;this._cachedIsFocused=void 0;this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._onWindowChange=this._register(new b);this.onWindowChange=this._onWindowChange.event;this._screenDprMonitor=this._register(new ls(this._window)),this._register(this.onWindowChange(s=>this._screenDprMonitor.setWindow(s))),this._register(j.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register(C(this._textarea,"focus",()=>this._isFocused=!0)),this._register(C(this._textarea,"blur",()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}},ls=class extends g{constructor(e){super();this._parentWindow=e;this._windowResizeListener=this._register(new P);this._onDprChange=this._register(new b);this.onDprChange=this._onDprChange.event;this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register(E(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=C(this._parentWindow,"resize",()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){!this._resolutionMediaMatchList||!this._outerListener||(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}};var zi=class extends g{constructor(){super();this.linkProviders=[];this._register(E(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{let t=this.linkProviders.indexOf(e);t!==-1&&this.linkProviders.splice(t,1)}}}};function qt(n,i,e){let t=e.getBoundingClientRect(),r=n.getComputedStyle(e),s=parseInt(r.getPropertyValue("padding-left"),10),o=parseInt(r.getPropertyValue("padding-top"),10);return[i.clientX-t.left-s,i.clientY-t.top-o]}function Qs(n,i,e,t,r,s,o,a,l){if(!s)return;let h=qt(n,i,e);return h[0]=Math.ceil((h[0]+(l?o/2:0))/o),h[1]=Math.ceil(h[1]/a),h[0]=Math.min(Math.max(h[0],1),t+(l?1:0)),h[1]=Math.min(Math.max(h[1],1),r),h}var vt=class{constructor(i,e){this._charSizeService=i;this._renderService=e}getCoords(i,e,t,r,s){return Qs(se(e),i,e,t,r,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,s)}getMouseReportCoords(i,e){let t=qt(se(e),i,e);if(this._charSizeService.hasValidSize)return t[0]=Math.min(Math.max(t[0],0),this._renderService.dimensions.css.canvas.width-1),t[1]=Math.min(Math.max(t[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(t[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(t[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(t[0]),y:Math.floor(t[1])}}};vt=y([m(0,Be),m(1,V)],vt);var en=typeof window=="object"?window:globalThis;function ce(n,i=0){return n[n.length-(1+i)]}function Jn(n,i,e){let t=null,r=null;if(typeof e.value=="function"?(t="value",r=e.value,r.length!==0&&console.warn("Memoize should only be used in functions with zero parameters")):typeof e.get=="function"&&(t="get",r=e.get),!r||!t)throw new Error("not supported");let s=`$memoize$${i}`,o=e;o[t]=function(...a){return this.hasOwnProperty(s)||Object.defineProperty(this,s,{configurable:!1,enumerable:!1,writable:!1,value:r.apply(this,a)}),this[s]}}var St=class St{constructor(i){this.element=i,this.next=St.Undefined,this.prev=St.Undefined}};St.Undefined=new St(void 0);var re=St,Gi=class{constructor(){this._first=re.Undefined;this._last=re.Undefined}push(i){return this._insert(i,!0)}_insert(i,e){let t=new re(i);if(this._first===re.Undefined)this._first=t,this._last=t;else if(e){let s=this._last;this._last=t,t.prev=s,s.next=t}else{let s=this._first;this._first=t,t.next=s,s.prev=t}let r=!1;return()=>{r||(r=!0,this._remove(t))}}_remove(i){if(i.prev!==re.Undefined&&i.next!==re.Undefined){let e=i.prev;e.next=i.next,i.next.prev=e}else i.prev===re.Undefined&&i.next===re.Undefined?(this._first=re.Undefined,this._last=re.Undefined):i.next===re.Undefined?(this._last=this._last.prev,this._last.next=re.Undefined):i.prev===re.Undefined&&(this._first=this._first.next,this._first.prev=re.Undefined)}*[Symbol.iterator](){let i=this._first;for(;i!==re.Undefined;)yield i.element,i=i.next}},he;(s=>(s.TAP="-xterm-gesturetap",s.CHANGE="-xterm-gesturechange",s.START="-xterm-gesturestart",s.END="-xterm-gesturesend",s.CONTEXT_MENU="-xterm-gesturecontextmenu"))(he||={});var K=class K extends g{constructor(){super();this._dispatched=!1;this._targets=new Gi;this._ignoreTargets=new Gi;this._activeTouches={},this._handle=null,this._lastSetTapCountTime=0;let e=en;this._register(C(e.document,"touchstart",t=>this._handleTouchStart(t),{passive:!1})),this._register(C(e.document,"touchend",t=>this._handleTouchEnd(e,t))),this._register(C(e.document,"touchmove",t=>this._handleTouchMove(t),{passive:!1}))}static addTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._targets.push(e);return E(t)}static ignoreTarget(e){if(!K.isTouchDevice())return g.None;K._instance||(K._instance=new K);let t=K._instance._ignoreTargets.push(e);return E(t)}static isTouchDevice(){return"ontouchstart"in en||navigator.maxTouchPoints>0}dispose(){this._handle&&(this._handle.dispose(),this._handle=null),super.dispose()}_handleTouchStart(e){let t=Date.now();this._handle&&(this._handle.dispose(),this._handle=null);for(let r=0,s=e.targetTouches.length;r=K._holdDelay&&Math.abs(h.initialPageX-ce(h.rollingPageX))<30&&Math.abs(h.initialPageY-ce(h.rollingPageY))<30){let c=this._newGestureEvent(he.CONTEXT_MENU,h.initialTarget);c.pageX=ce(h.rollingPageX),c.pageY=ce(h.rollingPageY),this._dispatchEvent(c)}else if(s===1){let c=ce(h.rollingPageX),u=ce(h.rollingPageY),_=ce(h.rollingTimestamps)-h.rollingTimestamps[0],p=c-h.rollingPageX[0],v=u-h.rollingPageY[0],f=[...this._targets].filter(S=>h.initialTarget instanceof Node&&S.contains(h.initialTarget));this._inertia(e,f,r,Math.abs(p)/_,p>0?1:-1,c,Math.abs(v)/_,v>0?1:-1,u)}this._dispatchEvent(this._newGestureEvent(he.END,h.initialTarget)),delete this._activeTouches[l.identifier]}this._dispatched&&(t.preventDefault(),t.stopPropagation(),this._dispatched=!1)}_newGestureEvent(e,t){let r=document.createEvent("CustomEvent");return r.initEvent(e,!1,!0),r.initialTarget=t,r.tapCount=0,r}_dispatchEvent(e){if(e.type===he.TAP){let t=new Date().getTime(),r;t-this._lastSetTapCountTime>K._clearTapCountTime?r=1:r=2,this._lastSetTapCountTime=t,e.tapCount=r}else(e.type===he.CHANGE||e.type===he.CONTEXT_MENU)&&(this._lastSetTapCountTime=0);if(e.initialTarget instanceof Node){for(let r of this._ignoreTargets)if(r.contains(e.initialTarget))return;let t=[];for(let r of this._targets)if(r.contains(e.initialTarget)){let s=0,o=e.initialTarget;for(;o&&o!==r;)s++,o=o.parentElement;t.push([s,r])}t.sort((r,s)=>r[0]-s[0]);for(let[,r]of t)r.dispatchEvent(e),this._dispatched=!0}}_inertia(e,t,r,s,o,a,l,h,d){this._handle=tt(e,()=>{let c=Date.now(),u=c-r,_=0,p=0,v=!0;s+=K._scrollFriction*u,l+=K._scrollFriction*u,s>0&&(v=!1,_=o*s*u),l>0&&(v=!1,p=h*l*u);let f=this._newGestureEvent(he.CHANGE);f.translationX=_,f.translationY=p,t.forEach(S=>S.dispatchEvent(f)),v||this._inertia(e,t,c,s,o,a+_,l,h,d+p)})}_handleTouchMove(e){let t=Date.now();for(let r=0,s=e.changedTouches.length;r3&&(a.rollingPageX.shift(),a.rollingPageY.shift(),a.rollingTimestamps.shift()),a.rollingPageX.push(o.pageX),a.rollingPageY.push(o.pageY),a.rollingTimestamps.push(t)}this._dispatched&&(e.preventDefault(),e.stopPropagation(),this._dispatched=!1)}};K._scrollFriction=-.005,K._holdDelay=700,K._clearTapCountTime=400,y([Jn],K,"isTouchDevice",1);var Vi=K;var gt=class{constructor(i,e,t,r,s,o,a,l,h){this._renderService=i;this._mouseCoordsService=e;this._mouseStateService=t;this._coreService=r;this._bufferService=s;this._optionsService=o;this._selectionService=a;this._logService=l;this._coreBrowserService=h;this._lastEvent=null;this._wheelPartialScroll=0;this._touchScrollAccumulator=0}bindMouse(i,e,t){let{element:r,document:s}=i,o={mouseup:null,wheel:null,mousedrag:null,mousemove:null},a={target:i,focus:t,requestedEvents:o},l={mouseup:h=>this._handleMouseUp(a,h),wheel:h=>this._handleWheel(a,h),mousedrag:h=>this._handleMouseDrag(a,h),mousemove:h=>this._handleMouseMove(a,h)};this._altMouseCursor=new cs(r,s,()=>this._mouseStateService.areMouseEventsActive&&!!this._optionsService.rawOptions.mouseEventsRequireAlt),e(this._altMouseCursor),e(this._mouseStateService.onProtocolChange(h=>{this._handleProtocolChange(a,l,h)})),e(this._optionsService.onSpecificOptionChange("mouseEventsRequireAlt",()=>{this._syncMouseModeState(r),this._altMouseCursor?.sync()})),this._mouseStateService.activeProtocol=this._mouseStateService.activeProtocol,e(E(()=>{o.mouseup&&s.removeEventListener("mouseup",o.mouseup),o.mousedrag&&s.removeEventListener("mousemove",o.mousedrag)})),e(C(r,"mousedown",h=>this._handleMouseDown(a,h))),e(C(r,"wheel",h=>this._handlePassiveWheel(a,h),{passive:!1})),e(Vi.addTarget(i.screenElement)),e(C(i.screenElement,he.START,()=>this._handleTouchStart())),e(C(i.screenElement,he.CHANGE,h=>this._handleTouchChange(a,h)))}_sendEvent(i,e){let t=this._mouseCoordsService.getMouseReportCoords(e,i.target.screenElement);if(!t)return!1;let r,s;switch(e.overrideType||e.type){case"mousemove":s=32,e.buttons===void 0?(r=3,e.button!==void 0&&(r=e.button<3?e.button:3)):r=e.buttons&1?0:e.buttons&4?1:e.buttons&2?2:3;break;case"mouseup":s=0,r=e.button<3?e.button:3;break;case"mousedown":s=1,r=e.button<3?e.button:3;break;case"wheel":if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;let a=e.deltaY;if(a===0||this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return!1;s=a<0?0:1,r=4;break;default:return!1}if(s===void 0||r===void 0||r>4||r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&!e.altKey)return!1;let o=r!==4&&this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive;return this._triggerMouseEvent({col:t.col,row:t.row,x:t.x,y:t.y,button:r,action:s,ctrl:e.ctrlKey,alt:o?!1:e.altKey,shift:e.shiftKey})}_handleMouseUp(i,e){this._sendEvent(i,e),e.buttons||(i.requestedEvents.mouseup&&i.target.document.removeEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.removeEventListener("mousemove",i.requestedEvents.mousedrag))}_handleWheel(i,e){return this._sendEvent(i,e),e.preventDefault(),e.stopPropagation(),!1}_handleMouseDrag(i,e){e.buttons&&this._sendEvent(i,e)}_handleMouseMove(i,e){e.buttons||this._sendEvent(i,e)}_handleMouseDown(i,e){e.preventDefault(),i.focus(),!(!this._mouseStateService.areMouseEventsActive||this._selectionService.shouldForceSelection(e))&&(this._sendEvent(i,e),i.requestedEvents.mouseup&&i.target.document.addEventListener("mouseup",i.requestedEvents.mouseup),i.requestedEvents.mousedrag&&i.target.document.addEventListener("mousemove",i.requestedEvents.mousedrag))}_handlePassiveWheel(i,e){if(!i.requestedEvents.wheel){if(!this._mouseStateService.allowCustomWheelEvent(e))return!1;if(!this._bufferService.buffer.hasScrollback){if(e.deltaY===0)return!1;if(this._consumeWheelEvent(e,this._renderService?.dimensions?.device?.cell?.height,this._coreBrowserService?.dpr)===0)return e.preventDefault(),e.stopPropagation(),!1;let s="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");return this._coreService.triggerDataEvent(s,!0),e.preventDefault(),e.stopPropagation(),!1}}}_handleTouchStart(){this._touchScrollAccumulator=0}_handleTouchChange(i,e){if(e.preventDefault(),e.stopPropagation(),i.requestedEvents.wheel){this._handleTouchScrollAsWheel(i,e);return}if(!this._bufferService.buffer.hasScrollback){this._handleTouchScrollAsKeys(e);return}i.target.handleTouchScroll?.(e.translationY)}_handleTouchScrollAsKeys(i){let e=this._renderService?.dimensions.css.cell.height;if(!e)return;this._touchScrollAccumulator-=i.translationY;let t=Math.trunc(this._touchScrollAccumulator/e);if(t===0)return;this._touchScrollAccumulator-=t*e;let r="\x1B"+(this._coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(t<0?"A":"B");for(let s=0;s0?1:-1),this._wheelPartialScroll%=1):i.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(s*=this._bufferService.rows),s}_triggerMouseEvent(i){if(i.col<0||i.col>=this._bufferService.cols||i.row<0||i.row>=this._bufferService.rows||i.button===4&&i.action===32||i.button===3&&i.action!==32||i.button!==4&&(i.action===2||i.action===3)||(i.col++,i.row++,i.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,i,this._mouseStateService.isPixelEncoding))||!this._mouseStateService.restrictMouseEvent(i))return!1;let e=this._mouseStateService.encodeMouseEvent(i);return e&&(this._mouseStateService.isDefaultEncoding?this._coreService.triggerBinaryEvent(e):this._coreService.triggerDataEvent(e,!0)),this._lastEvent=i,!0}_explainEvents(i){return{down:!!(i&1),up:!!(i&2),drag:!!(i&4),move:!!(i&8),wheel:!!(i&16)}}_equalEvents(i,e,t){if(t){if(i.x!==e.x||i.y!==e.y)return!1}else if(i.col!==e.col||i.row!==e.row)return!1;return!(i.button!==e.button||i.action!==e.action||i.ctrl!==e.ctrl||i.alt!==e.alt||i.shift!==e.shift)}};gt=y([m(0,V),m(1,Pe),m(2,Me),m(3,Y),m(4,D),m(5,R),m(6,Si),m(7,fe),m(8,G)],gt);var cs=class{constructor(i,e,t){this._element=i;this._document=e;this._isActive=t;this._listeners=new P}dispose(){this._listeners.dispose()}sync(){if(this._listeners.clear(),!this._isActive())return;let i=new pe,e=r=>this.syncFromModifier(r);i.add(C(this._document,"keydown",e)),i.add(C(this._document,"keyup",e)),i.add(C(this._element,"mousemove",e));let t=this._element.ownerDocument?.defaultView;t&&i.add(C(t,"blur",()=>{this._isActive()&&this.resetClass()})),this._listeners.value=i}resetClass(){this._updateClass(!1)}syncFromModifier(i){this._isActive()&&this._updateClass(i.getModifierState("Alt"))}_updateClass(i){i?this._element.classList.add("enable-mouse-events"):this._element.classList.remove("enable-mouse-events")}};var $i=class{constructor(i,e){this._renderCallback=i;this._coreBrowserService=e;this._refreshCallbacks=[]}dispose(){this._animationFrame!==void 0&&(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(i){return this._refreshCallbacks.push(i),this._animationFrame??=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()),this._animationFrame}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0){this._runRefreshCallbacks();return}let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(let i of this._refreshCallbacks)i(0);this._refreshCallbacks=[]}};var qi=class{constructor(i){this._tasks=[];this._i=0;this._logService=i}enqueue(i){this._tasks.push(i),this._start()}flush(){for(;this._is){r-e<-20&&this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-e))}ms`),this._start();return}r=s}this.clear()}},hs=class extends qi{_requestCallback(i){return setTimeout(()=>i(this._createDeadline(16)))}_cancelCallback(i){clearTimeout(i)}_createDeadline(i){let e=performance.now()+i;return{timeRemaining:()=>Math.max(0,e-performance.now())}}},ds=class extends qi{_requestCallback(i){return requestIdleCallback(i)}_cancelCallback(i){cancelIdleCallback(i)}},It="requestIdleCallback"in globalThis?ds:hs,Xi=class{constructor(i){this._queue=new It(i)}set(i){this._queue.clear(),this._queue.enqueue(i)}flush(){this._queue.flush()}dispose(){this._queue.clear()}};var Ct=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._rowCount=e;this._optionsService=r;this._logService=s;this._charSizeService=o;this._coreService=a;this._coreBrowserService=d;this._renderer=this._register(new P);this._observerDisposable=this._register(new P);this._isPaused=!1;this._needsFullRefresh=!1;this._isNextRenderRedrawOnly=!0;this._needsSelectionRefresh=!1;this._canvasWidth=0;this._canvasHeight=0;this._selectionState={start:void 0,end:void 0,columnSelectMode:!1};this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._onRenderedViewportChange=this._register(new b);this.onRenderedViewportChange=this._onRenderedViewportChange.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onRefreshRequest=this._register(new b);this.onRefreshRequest=this._onRefreshRequest.event;this._pausedResizeTask=this._register(new Xi(this._logService)),this._renderDebouncer=new $i((u,_)=>this._renderRows(u,_),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new us(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register(E(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(h.onResize(()=>this._fullRefresh())),this._register(h.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(l.onDecorationRegistered(()=>this._fullRefresh())),this._register(l.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange(["drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio","rescaleOverlappingGlyphs"],()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange(["cursorBlink","cursorStyle"],()=>this.refreshRows(h.buffer.y,h.buffer.y,void 0,!0))),this._register(c.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(u=>this._registerIntersectionObserver(u,t)))}get dimensions(){return this._renderer.value.dimensions}_registerIntersectionObserver(e,t){if("IntersectionObserver"in e){let r=new e.IntersectionObserver(s=>this._handleIntersectionChange(s[s.length-1]),{threshold:0});this._observerDisposable.value=E(()=>{this._intersectionObserver?.disconnect(),this._intersectionObserver=void 0}),this._intersectionObserver=r,r.observe(t)}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused),!this._isPaused&&!this._charSizeService.hasValidSize&&this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,r=!1,s=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let o=this._syncOutputHandler.flush();o&&(e=Math.min(e,o.start),t=Math.max(t,o.end)),s||(this._isNextRenderRedrawOnly=!1),r?this._renderRows(e,t):this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){if(this._renderer.value){if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0}}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(t=>this.refreshRows(t.start,t.end,t.sync,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,r){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=r,this._renderer.value?.handleSelectionChanged(e,t,r)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};Ct=y([m(2,R),m(3,fe),m(4,Be),m(5,Y),m(6,ge),m(7,D),m(8,G),m(9,_e)],Ct);var us=class{constructor(i,e,t){this._coreBrowserService=i;this._coreService=e;this._onTimeout=t;this._start=0;this._end=0;this._isBuffering=!1}bufferRows(i,e){this._isBuffering?(this._start=Math.min(this._start,i),this._end=Math.max(this._end,e)):(this._start=i,this._end=e,this._isBuffering=!0),this._timeout??=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3)}flush(){if(this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;let i={start:this._start,end:this._end};return this._isBuffering=!1,i}dispose(){this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}};function tn(n,i,e,t){let r=e.buffer.x,s=e.buffer.y;if(!e.buffer.hasScrollback)return ro(r,s,n,i,e,t)+Yi(s,i,e,t)+so(r,s,n,i,e,t);let o;if(s===i)return o=r>n?"D":"C",Yt(Math.abs(r-n),Xt(o,t));o=s>i?"D":"C";let a=Math.abs(s-i),l=io(s>i?n:r,e)+(a-1)*e.cols+1+to(s>i?r:n,e);return Yt(l,Xt(o,t))}function to(n,i){return n-1}function io(n,i){return i.cols-n}function ro(n,i,e,t,r,s){return Yi(i,t,r,s).length===0?"":Yt(sn(n,i,n,i-$e(i,r),!1,r).length,Xt("D",s))}function Yi(n,i,e,t){let r=n-$e(n,e),s=i-$e(i,e),o=Math.abs(r-s)-no(n,i,e);return Yt(o,Xt(rn(n,i),t))}function so(n,i,e,t,r,s){let o;Yi(i,t,r,s).length>0?o=t-$e(t,r):o=i;let a=t,l=oo(n,i,e,t,r,s);return Yt(sn(n,o,e,a,l==="C",r).length,Xt(l,s))}function no(n,i,e){let t=0,r=n-$e(n,e),s=i-$e(i,e);for(let o=0;o=0&&n0?o=t-$e(t,r):o=i,n=e&&oi?"A":"B"}function sn(n,i,e,t,r,s){let o=n,a=i,l="";for(;(o!==e||a!==t)&&a>=0&&as.cols-1?(l+=s.buffer.translateBufferLineToString(a,!1,n,o),o=0,n=0,a++):!r&&o<0&&(l+=s.buffer.translateBufferLineToString(a,!1,0,n+1),o=s.cols-1,n=o,a--);return l+s.buffer.translateBufferLineToString(a,!1,n,o)}function Xt(n,i){let e=i?"O":"[";return"\x1B"+e+n}function Yt(n,i){n=Math.floor(n);let e="";for(let t=0;tthis._bufferService.cols?i%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)-1]:[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[i,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){let i=this.selectionStart[0]+this.selectionStartLength;return i>this._bufferService.cols?[i%this._bufferService.cols,this.selectionStart[1]+Math.floor(i/this._bufferService.cols)]:[Math.max(i,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){let i=this.selectionStart,e=this.selectionEnd;return!i||!e?!1:i[1]>e[1]||i[1]===e[1]&&i[0]>e[0]}handleTrim(i){return this.selectionStart&&(this.selectionStart[1]-=i),this.selectionEnd&&(this.selectionEnd[1]-=i),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):this.selectionStart&&this.selectionStart[1]<0?(this.selectionStart=[0,0],!0):!1}};function fs(n,i){if(n.start.y>n.end.y)throw new Error(`Buffer range end (${n.end.x}, ${n.end.y}) cannot be before start (${n.start.x}, ${n.start.y})`);return i*(n.end.y-n.start.y)+(n.end.x-n.start.x+1)}var ao="\xA0",lo=new RegExp(ao,"g");var Et=class extends g{constructor(e,t,r,s,o,a,l,h,d,c){super();this._element=e;this._screenElement=t;this._linkifier=r;this._bufferService=s;this._coreService=o;this._mouseCoordsService=a;this._optionsService=l;this._mouseStateService=h;this._renderService=d;this._coreBrowserService=c;this._dragScrollAmount=0;this._enabled=!0;this._trimListener=this._register(new P);this._workCell=new F;this._mouseDownTimeStamp=0;this._oldHasSelection=!1;this._oldSelectionStart=void 0;this._oldSelectionEnd=void 0;this._onLinuxMouseSelection=this._register(new b);this.onLinuxMouseSelection=this._onLinuxMouseSelection.event;this._onRedrawRequest=this._register(new b);this.onRequestRedraw=this._onRedrawRequest.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onRequestScrollLines=this._register(new b);this.onRequestScrollLines=this._onRequestScrollLines.event;this._mouseMoveListener=u=>this._handleMouseMove(u),this._mouseUpListener=u=>this._handleMouseUp(u),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener.value=this._bufferService.buffer.lines.onTrim(u=>this._handleTrim(u)),this._register(this._bufferService.buffers.onBufferActivate(u=>this._handleBufferActivate(u))),this.enable(),this._model=new ji(this._bufferService),this._activeSelectionMode=0,this._register(E(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(u=>{u.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!e||!t?!1:e[0]!==t[0]||e[1]!==t[1]}get selectionText(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";let r=this._bufferService.buffer,s=[];if(this._activeSelectionMode===3){if(e[0]===t[0])return"";let a=e[0]a.replace(lo," ")).join(Ue?`\r `:` `)}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh())),zt&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:this._activeSelectionMode===3})}_isClickInSelection(e){let t=this._getMouseBufferCoords(e),r=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!r||!s||!t?!1:this._areCoordsInSelection(t,r,s)}isCellInSelection(e,t){let r=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!r||!s?!1:this._areCoordsInSelection([e,t],r,s)}_areCoordsInSelection(e,t,r){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){let r=this._linkifier.currentLink?.link?.range;if(r)return this._model.selectionStart=[r.start.x-1,r.start.y-1],this._model.selectionStartLength=fs(r,this._bufferService.cols),this._model.selectionEnd=void 0,!0;let s=this._getMouseBufferCoords(e);return s?(this._selectWordAt(s,t),this._model.selectionEnd=void 0,!0):!1}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){let t=this._mouseCoordsService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=qt(this._coreBrowserService.window,e,this._screenElement)[1],r=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=r?0:(t>r&&(t-=r),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(t*14))}shouldForceSelection(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!e.altKey:ie?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(e.button===2&&this.hasSelection)&&e.button===0&&!(this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive&&e.altKey)){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):e.detail===1?this._handleSingleClick(e):e.detail===2?this._handleDoubleClick(e):e.detail===3&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){let t=this.hasSelection;if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0,t&&this._fireOnSelectionChange(this._model.finalSelectionStart,this._model.finalSelectionEnd,!1);let r=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);r&&r.length!==this._model.selectionStart[0]&&r.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){let t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return this._optionsService.rawOptions.mouseEventsRequireAlt&&this._mouseStateService.areMouseEventsActive?!1:e.altKey&&!(ie&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;let t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd){this.refresh(!0);return}this._activeSelectionMode===2?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));let r=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows-1,e.lines.length-1)):(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){let t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){let r=this._mouseCoordsService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(r&&r[0]!==void 0&&r[1]!==void 0){let s=tn(r[0]-1,r[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(s,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,r=!!e&&!!t&&(e[0]!==t[0]||e[1]!==t[1]);if(!r){this._oldHasSelection&&this._fireOnSelectionChange(e,t,r);return}!e||!t||(!this._oldSelectionStart||!this._oldSelectionEnd||e[0]!==this._oldSelectionStart[0]||e[1]!==this._oldSelectionStart[1]||t[0]!==this._oldSelectionEnd[0]||t[1]!==this._oldSelectionEnd[1])&&this._fireOnSelectionChange(e,t,r)}_fireOnSelectionChange(e,t,r){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=r,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.value=e.activeBuffer.lines.onTrim(t=>this._handleTrim(t))}_convertViewportColToCharacterIndex(e,t){let r=t;for(let s=0;t>=s;s++){let o=e.loadCell(s,this._workCell).getChars().length;this._workCell.getWidth()===0?r--:o>1&&t!==s&&(r+=o-1)}return r}setSelection(e,t,r){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=r,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,r=!0,s=!0){if(e[0]>=this._bufferService.cols)return;let o=this._bufferService.buffer,a=o.lines.get(e[1]);if(!a)return;let l=o.translateBufferLineToString(e[1],!1),h=this._convertViewportColToCharacterIndex(a,e[0]),d=h,c=e[0]-h,u=0,_=0,p=0,v=0;if(l.charAt(h)===" "){for(;h>0&&l.charAt(h-1)===" ";)h--;for(;d1&&(v+=L-1,d+=L-1);I>0&&h>0&&!this._isCharWordSeparator(a.loadCell(I-1,this._workCell));){a.loadCell(I-1,this._workCell);let T=this._workCell.getChars().length;this._workCell.getWidth()===0?(u++,I--):T>1&&(p+=T-1,h-=T-1),h--,I--}for(;w1&&(v+=T-1,d+=T-1),d++,w++}}d++;let f=h+c-u+p,S=Math.min(this._bufferService.cols,d-h+u+_-p-v);if(!(!t&&l.slice(h,d).trim()==="")){if(r&&f===0&&a.getCodePoint(0)!==32){let I=o.lines.get(e[1]-1);if(I&&a.isWrapped&&I.getCodePoint(this._bufferService.cols-1)!==32){let w=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(w){let L=this._bufferService.cols-w.start;f-=L,S+=L}}}if(s&&f+S===this._bufferService.cols&&a.getCodePoint(this._bufferService.cols-1)!==32){let I=o.lines.get(e[1]+1);if(I?.isWrapped&&I.getCodePoint(0)!==32){let w=this._getWordAt([0,e[1]+1],!1,!1,!0);w&&(S+=w.length)}}return{start:f,length:S}}}_selectWordAt(e,t){let r=this._getWordAt(e,t);if(r){for(;r.start<0;)r.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[r.start,e[1]],this._model.selectionStartLength=r.length}}_selectToWordAt(e){let t=this._getWordAt(e,!0);if(t){let r=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,r--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,r++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,r]}}_isCharWordSeparator(e){return e.getWidth()===0?!1:this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){let t=this._bufferService.buffer.getWrappedRangeForLine(e),r={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=fs(r,this._bufferService.cols)}};Et=y([m(3,D),m(4,Y),m(5,Pe),m(6,R),m(7,Me),m(8,V),m(9,G)],Et);var jt=class{constructor(){this._data={}}set(i,e,t){this._data[i]||(this._data[i]={}),this._data[i][e]=t}get(i,e){return this._data[i]?this._data[i][e]:void 0}clear(){this._data={}}};var Zt=class{constructor(){this._color=new jt;this._css=new jt}setCss(i,e,t){this._css.set(i,e,t)}getCss(i,e){return this._css.get(i,e)}setColor(i,e,t){this._color.set(i,e,t)}getColor(i,e){return this._color.get(i,e)}clear(){this._color.clear(),this._css.clear()}};var $=Object.freeze((()=>{let n=[B.toColor("#2e3436"),B.toColor("#cc0000"),B.toColor("#4e9a06"),B.toColor("#c4a000"),B.toColor("#3465a4"),B.toColor("#75507b"),B.toColor("#06989a"),B.toColor("#d3d7cf"),B.toColor("#555753"),B.toColor("#ef2929"),B.toColor("#8ae234"),B.toColor("#fce94f"),B.toColor("#729fcf"),B.toColor("#ad7fa8"),B.toColor("#34e2e2"),B.toColor("#eeeeec")],i=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=i[e/36%6|0],r=i[e/6%6|0],s=i[e%6];n.push({css:O.toCss(t,r,s),rgba:O.toRgba(t,r,s)})}for(let e=0;e<24;e++){let t=8+e*10;n.push({css:O.toCss(t,t,t),rgba:O.toRgba(t,t,t)})}return n})());var qe=B.toColor("#ffffff"),Qt=B.toColor("#000000"),nn=B.toColor("#ffffff"),on=Qt,Jt={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117},co=qe,yt=class extends g{constructor(e){super();this._optionsService=e;this._contrastCache=new Zt;this._halfContrastCache=new Zt;this._onChangeColors=this._register(new b);this.onChangeColors=this._onChangeColors.event;this._colors={foreground:qe,background:Qt,cursor:nn,cursorAccent:on,selectionForeground:void 0,selectionBackgroundTransparent:Jt,selectionBackgroundOpaque:k.blend(Qt,Jt),selectionInactiveBackgroundTransparent:Jt,selectionInactiveBackgroundOpaque:k.blend(Qt,Jt),scrollbarSliderBackground:k.opacity(qe,.2),scrollbarSliderHoverBackground:k.opacity(qe,.4),scrollbarSliderActiveBackground:k.opacity(qe,.5),overviewRulerBorder:qe,ansi:$.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange("theme",()=>this._setTheme(this._optionsService.rawOptions.theme)))}get colors(){return this._colors}_setTheme(e={}){let t=this._colors;if(t.foreground=M(e.foreground,qe),t.background=M(e.background,Qt),t.cursor=k.blend(t.background,M(e.cursor,nn)),t.cursorAccent=k.blend(t.background,M(e.cursorAccent,on)),t.selectionBackgroundTransparent=M(e.selectionBackground,Jt),t.selectionBackgroundOpaque=k.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=M(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=k.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?M(e.selectionForeground,ts):void 0,t.selectionForeground===ts&&(t.selectionForeground=void 0),k.isOpaque(t.selectionBackgroundTransparent)&&(t.selectionBackgroundTransparent=k.opacity(t.selectionBackgroundTransparent,.3)),k.isOpaque(t.selectionInactiveBackgroundTransparent)&&(t.selectionInactiveBackgroundTransparent=k.opacity(t.selectionInactiveBackgroundTransparent,.3)),t.scrollbarSliderBackground=M(e.scrollbarSliderBackground,k.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=M(e.scrollbarSliderHoverBackground,k.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=M(e.scrollbarSliderActiveBackground,k.opacity(t.foreground,.5)),t.overviewRulerBorder=M(e.overviewRulerBorder,co),t.ansi=$.slice(),t.ansi[0]=M(e.black,$[0]),t.ansi[1]=M(e.red,$[1]),t.ansi[2]=M(e.green,$[2]),t.ansi[3]=M(e.yellow,$[3]),t.ansi[4]=M(e.blue,$[4]),t.ansi[5]=M(e.magenta,$[5]),t.ansi[6]=M(e.cyan,$[6]),t.ansi[7]=M(e.white,$[7]),t.ansi[8]=M(e.brightBlack,$[8]),t.ansi[9]=M(e.brightRed,$[9]),t.ansi[10]=M(e.brightGreen,$[10]),t.ansi[11]=M(e.brightYellow,$[11]),t.ansi[12]=M(e.brightBlue,$[12]),t.ansi[13]=M(e.brightMagenta,$[13]),t.ansi[14]=M(e.brightCyan,$[14]),t.ansi[15]=M(e.brightWhite,$[15]),e.extendedAnsi){let r=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let s=0;s"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};function ln(n,i,e,t){let r={type:0,cancel:!1,key:void 0},s=(n.shiftKey?1:0)|(n.altKey?2:0)|(n.ctrlKey?4:0)|(n.metaKey?8:0);switch(n.keyCode){case 0:n.key==="UIKeyInputUpArrow"?i?r.key="\x1BOA":r.key="\x1B[A":n.key==="UIKeyInputLeftArrow"?i?r.key="\x1BOD":r.key="\x1B[D":n.key==="UIKeyInputRightArrow"?i?r.key="\x1BOC":r.key="\x1B[C":n.key==="UIKeyInputDownArrow"&&(i?r.key="\x1BOB":r.key="\x1B[B");break;case 8:r.key=n.ctrlKey?"\b":"\x7F",n.altKey&&(r.key="\x1B"+r.key);break;case 9:if(n.shiftKey){r.key="\x1B[Z";break}r.key=" ",r.cancel=!0;break;case 13:n.key==="c"&&n.ctrlKey?r.key="":r.key=n.altKey?"\x1B\r":"\r",r.cancel=!0;break;case 27:r.key="\x1B",n.altKey&&(r.key="\x1B\x1B"),r.cancel=!0;break;case 37:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"D":i?r.key="\x1BOD":r.key="\x1B[D";break;case 39:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"C":i?r.key="\x1BOC":r.key="\x1B[C";break;case 38:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"A":i?r.key="\x1BOA":r.key="\x1B[A";break;case 40:if(n.metaKey)break;s?r.key="\x1B[1;"+(s+1)+"B":i?r.key="\x1BOB":r.key="\x1B[B";break;case 45:!n.shiftKey&&!n.ctrlKey&&(r.key="\x1B[2~");break;case 46:s?r.key="\x1B[3;"+(s+1)+"~":r.key="\x1B[3~";break;case 36:s?r.key="\x1B[1;"+(s+1)+"H":i?r.key="\x1BOH":r.key="\x1B[H";break;case 35:s?r.key="\x1B[1;"+(s+1)+"F":i?r.key="\x1BOF":r.key="\x1B[F";break;case 33:n.shiftKey?r.type=2:n.ctrlKey?r.key="\x1B[5;"+(s+1)+"~":r.key="\x1B[5~";break;case 34:n.shiftKey?r.type=3:n.ctrlKey?r.key="\x1B[6;"+(s+1)+"~":r.key="\x1B[6~";break;case 112:s?r.key="\x1B[1;"+(s+1)+"P":r.key="\x1BOP";break;case 113:s?r.key="\x1B[1;"+(s+1)+"Q":r.key="\x1BOQ";break;case 114:s?r.key="\x1B[1;"+(s+1)+"R":r.key="\x1BOR";break;case 115:s?r.key="\x1B[1;"+(s+1)+"S":r.key="\x1BOS";break;case 116:s?r.key="\x1B[15;"+(s+1)+"~":r.key="\x1B[15~";break;case 117:s?r.key="\x1B[17;"+(s+1)+"~":r.key="\x1B[17~";break;case 118:s?r.key="\x1B[18;"+(s+1)+"~":r.key="\x1B[18~";break;case 119:s?r.key="\x1B[19;"+(s+1)+"~":r.key="\x1B[19~";break;case 120:s?r.key="\x1B[20;"+(s+1)+"~":r.key="\x1B[20~";break;case 121:s?r.key="\x1B[21;"+(s+1)+"~":r.key="\x1B[21~";break;case 122:s?r.key="\x1B[23;"+(s+1)+"~":r.key="\x1B[23~";break;case 123:s?r.key="\x1B[24;"+(s+1)+"~":r.key="\x1B[24~";break;default:if(n.ctrlKey&&!n.shiftKey&&!n.altKey&&!n.metaKey)n.keyCode>=65&&n.keyCode<=90?r.key=String.fromCharCode(n.keyCode-64):n.keyCode===32?r.key="\0":n.keyCode>=51&&n.keyCode<=55?r.key=String.fromCharCode(n.keyCode-51+27):n.keyCode===56?r.key="\x7F":n.key==="/"?r.key="":n.keyCode===219?r.key="\x1B":n.keyCode===220?r.key="":n.keyCode===221&&(r.key="");else if((!e||t)&&n.altKey&&!n.metaKey){let a=ho[n.keyCode]?.[n.shiftKey?1:0];if(a)r.key="\x1B"+a;else if(n.keyCode>=65&&n.keyCode<=90){let l=n.ctrlKey?n.keyCode-64:n.keyCode+32,h=String.fromCharCode(l);n.shiftKey&&(h=h.toUpperCase()),r.key="\x1B"+h}else if(n.keyCode===32)r.key="\x1B"+(n.ctrlKey?"\0":" ");else if(n.key==="Dead"&&n.code.startsWith("Key")){let l=n.code.slice(3,4);n.shiftKey||(l=l.toLowerCase()),r.key="\x1B"+l,r.cancel=!0}}else if(e&&!n.altKey&&!n.ctrlKey&&!n.shiftKey&&n.metaKey)n.keyCode===65&&(r.type=1);else if(n.key&&!n.ctrlKey&&!n.altKey&&!n.metaKey&&n.keyCode>=48&&n.key.length===1)r.key=n.key;else if(n.key&&n.ctrlKey&&n.shiftKey)switch(n.code){case"Minus":r.key="";break;case"Digit2":r.key="\0";break;case"Digit6":r.key="";break}break}return r}var ei=class{constructor(){this._functionalKeyCodes={Escape:27,Enter:13,Tab:9,Backspace:127,CapsLock:57358,ScrollLock:57359,NumLock:57360,PrintScreen:57361,Pause:57362,ContextMenu:57363,F13:57376,F14:57377,F15:57378,F16:57379,F17:57380,F18:57381,F19:57382,F20:57383,F21:57384,F22:57385,F23:57386,F24:57387,F25:57388,KP_0:57399,KP_1:57400,KP_2:57401,KP_3:57402,KP_4:57403,KP_5:57404,KP_6:57405,KP_7:57406,KP_8:57407,KP_9:57408,KP_Decimal:57409,KP_Divide:57410,KP_Multiply:57411,KP_Subtract:57412,KP_Add:57413,KP_Enter:57414,KP_Equal:57415,ShiftLeft:57441,ShiftRight:57447,ControlLeft:57442,ControlRight:57448,AltLeft:57443,AltRight:57449,MetaLeft:57444,MetaRight:57450,MediaPlayPause:57430,MediaStop:57432,MediaTrackNext:57435,MediaTrackPrevious:57436,AudioVolumeDown:57438,AudioVolumeUp:57439,AudioVolumeMute:57440};this._csiTildeKeys={Insert:2,Delete:3,PageUp:5,PageDown:6,F5:15,F6:17,F7:18,F8:19,F9:20,F10:21,F11:23,F12:24};this._csiLetterKeys={ArrowUp:"A",ArrowDown:"B",ArrowRight:"C",ArrowLeft:"D",Home:"H",End:"F"};this._ss3FunctionKeys={F1:"P",F2:"Q",F3:"R",F4:"S"}}_getNumpadKeyCode(i){if(i.code.startsWith("Numpad")){let e=i.code.slice(6);if(e>="0"&&e<="9")return 57399+parseInt(e,10);switch(e){case"Decimal":return 57409;case"Divide":return 57410;case"Multiply":return 57411;case"Subtract":return 57412;case"Add":return 57413;case"Enter":return 57414;case"Equal":return 57415}}}_getModifierKeyCode(i){switch(i.code){case"ShiftLeft":return 57441;case"ShiftRight":return 57447;case"ControlLeft":return 57442;case"ControlRight":return 57448;case"AltLeft":return 57443;case"AltRight":return 57449;case"MetaLeft":return 57444;case"MetaRight":return 57450}}_encodeModifiers(i){let e=0;return i.shiftKey&&(e|=1),i.altKey&&(e|=2),i.ctrlKey&&(e|=4),i.metaKey&&(e|=8),e>0?e+1:0}_getKeyCode(i,e){let t=this._getNumpadKeyCode(i);if(t!==void 0)return t;let r=this._getModifierKeyCode(i);if(r!==void 0)return r;let s=this._functionalKeyCodes[i.key];if(s!==void 0)return s;if((i.shiftKey||e&&i.altKey)&&i.code){if(i.code.startsWith("Digit")&&i.code.length===6){let o=i.code.charAt(5);if(o>="0"&&o<="9")return o.charCodeAt(0)}if(i.code.startsWith("Key")&&i.code.length===4)return i.code.charAt(3).toLowerCase().charCodeAt(0)}if(i.key.length===1){let o=i.key.codePointAt(0);return o>=65&&o<=90?o+32:o}}_isModifierKey(i){return i.key==="Shift"||i.key==="Control"||i.key==="Alt"||i.key==="Meta"}_isLockKey(i){return i.key==="CapsLock"||i.key==="NumLock"||i.key==="ScrollLock"}_buildCsiLetterSequence(i,e,t,r){let s=r&&t!==1;if(e>0||s){let o="\x1B[1;"+(e>0?e:"1");return s&&(o+=":"+t),o+=i,o}return"\x1B["+i}_buildSs3Sequence(i,e,t,r){let s=r&&t!==1;if(e>0||s){let o="\x1B[1;"+(e>0?e:"1");return s&&(o+=":"+t),o+=i,o}return"\x1BO"+i}_buildCsiTildeSequence(i,e,t,r){let s=r&&t!==1,o="\x1B["+i;return(e>0||s)&&(o+=";"+(e>0?e:"1"),s&&(o+=":"+t)),o+="~",o}_buildCsiUSequence(i,e,t,r,s,o,a){let l=!!(s&2),h=!!(s&4),d="\x1B["+e,c;h&&i.shiftKey&&i.key.length===1&&!o&&!a&&(c=i.key.codePointAt(0),d+=":"+c);let _=!!(s&16)&&r!==3&&i.key.length===1&&!o&&!a&&!i.ctrlKey?i.key.codePointAt(0):void 0,p=l&&r!==1&&(r===3||_===void 0);return(t>0||p||_!==void 0)&&(d+=";",t>0?d+=t:p&&(d+="1"),p&&(d+=":"+r)),_!==void 0&&(d+=";"+_),d+="u",d}evaluate(i,e,t=1,r=!1){let s={type:0,cancel:!1,key:void 0},o=this._encodeModifiers(i),a=this._isModifierKey(i),l=!!(e&2);if(!l&&t===3||a&&!(e&8)||this._isLockKey(i)&&!(e&8))return s;let h=this._csiLetterKeys[i.key];if(h)return s.key=this._buildCsiLetterSequence(h,o,t,l),s.cancel=!0,s;let d=this._ss3FunctionKeys[i.key];if(d)return s.key=this._buildSs3Sequence(d,o,t,l),s.cancel=!0,s;let c=this._csiTildeKeys[i.key];if(c!==void 0)return s.key=this._buildCsiTildeSequence(c,o,t,l),s.cancel=!0,s;let u=this._getKeyCode(i,r);if(u===void 0)return s;let _=u===13||u===9||u===127;if(_&&t===3&&!(e&8))return s;let p=this._functionalKeyCodes[i.key]!==void 0||this._getNumpadKeyCode(i)!==void 0;if(!!(e&8||l&&t===3||(e&1||l)&&(p&&!_||o>0&&i.key.length!==1||o-1>1)))s.key=this._buildCsiUSequence(i,u,o,t,e,p,a),s.cancel=!0;else{let f=u===13?"\r":u===9?" ":u===127?"\x7F":void 0;f?s.key=f:i.key.length===1&&!i.ctrlKey&&!i.altKey&&!i.metaKey&&(s.key=i.key)}return s}static shouldUseProtocol(i){return i>0}};var Zi=class{constructor(){this._codeToVk={KeyA:65,KeyB:66,KeyC:67,KeyD:68,KeyE:69,KeyF:70,KeyG:71,KeyH:72,KeyI:73,KeyJ:74,KeyK:75,KeyL:76,KeyM:77,KeyN:78,KeyO:79,KeyP:80,KeyQ:81,KeyR:82,KeyS:83,KeyT:84,KeyU:85,KeyV:86,KeyW:87,KeyX:88,KeyY:89,KeyZ:90,Digit0:48,Digit1:49,Digit2:50,Digit3:51,Digit4:52,Digit5:53,Digit6:54,Digit7:55,Digit8:56,Digit9:57,F1:112,F2:113,F3:114,F4:115,F5:116,F6:117,F7:118,F8:119,F9:120,F10:121,F11:122,F12:123,F13:124,F14:125,F15:126,F16:127,F17:128,F18:129,F19:130,F20:131,F21:132,F22:133,F23:134,F24:135,Numpad0:96,Numpad1:97,Numpad2:98,Numpad3:99,Numpad4:100,Numpad5:101,Numpad6:102,Numpad7:103,Numpad8:104,Numpad9:105,NumpadMultiply:106,NumpadAdd:107,NumpadSeparator:108,NumpadSubtract:109,NumpadDecimal:110,NumpadDivide:111,NumpadEnter:13,NumLock:144,ArrowUp:38,ArrowDown:40,ArrowLeft:37,ArrowRight:39,Home:36,End:35,PageUp:33,PageDown:34,Insert:45,Delete:46,ShiftLeft:16,ShiftRight:16,ControlLeft:17,ControlRight:17,AltLeft:18,AltRight:18,MetaLeft:91,MetaRight:92,CapsLock:20,ScrollLock:145,Escape:27,Enter:13,Tab:9,Space:32,Backspace:8,Pause:19,ContextMenu:93,PrintScreen:44,Semicolon:186,Equal:187,Comma:188,Minus:189,Period:190,Slash:191,Backquote:192,BracketLeft:219,Backslash:220,BracketRight:221,Quote:222,IntlBackslash:226};this._codeToScancode={KeyQ:16,KeyW:17,KeyE:18,KeyR:19,KeyT:20,KeyY:21,KeyU:22,KeyI:23,KeyO:24,KeyP:25,KeyA:30,KeyS:31,KeyD:32,KeyF:33,KeyG:34,KeyH:35,KeyJ:36,KeyK:37,KeyL:38,KeyZ:44,KeyX:45,KeyC:46,KeyV:47,KeyB:48,KeyN:49,KeyM:50,Digit1:2,Digit2:3,Digit3:4,Digit4:5,Digit5:6,Digit6:7,Digit7:8,Digit8:9,Digit9:10,Digit0:11,F1:59,F2:60,F3:61,F4:62,F5:63,F6:64,F7:65,F8:66,F9:67,F10:68,F11:87,F12:88,Numpad0:82,Numpad1:79,Numpad2:80,Numpad3:81,Numpad4:75,Numpad5:76,Numpad6:77,Numpad7:71,Numpad8:72,Numpad9:73,NumpadMultiply:55,NumpadAdd:78,NumpadSubtract:74,NumpadDecimal:83,NumpadDivide:53,NumpadEnter:28,NumLock:69,ArrowUp:72,ArrowDown:80,ArrowLeft:75,ArrowRight:77,Home:71,End:79,PageUp:73,PageDown:81,Insert:82,Delete:83,ShiftLeft:42,ShiftRight:54,ControlLeft:29,ControlRight:29,AltLeft:56,AltRight:56,CapsLock:58,ScrollLock:70,Escape:1,Enter:28,Tab:15,Space:57,Backspace:14,Pause:69,Semicolon:39,Equal:13,Comma:51,Minus:12,Period:52,Slash:53,Backquote:41,BracketLeft:26,Backslash:43,BracketRight:27,Quote:40};this._enhancedKeyCodes=new Set(["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End","PageUp","PageDown","Insert","Delete","NumpadEnter","NumpadDivide","ControlRight","AltRight","PrintScreen","Pause","ContextMenu","MetaLeft","MetaRight"]);this._keyToControlChar={Enter:13,Backspace:8,Tab:9,Escape:27}}_getVirtualKeyCode(i){let e=this._codeToVk[i.code];return e!==void 0?e:i.keyCode||0}_getScanCode(i){return this._codeToScancode[i.code]||0}_getUnicodeChar(i){if(i.ctrlKey&&!i.altKey&&!i.metaKey){if(i.key==="Enter")return 10;if(i.key==="Backspace")return 127}let e=this._keyToControlChar[i.key];if(e!==void 0)return e;if(i.key.length===1){let t=i.key.codePointAt(0)||0;if(i.ctrlKey&&!i.altKey&&!i.metaKey){if(t>=65&&t<=90)return t-64;if(t>=97&&t<=122)return t-96}return t}return 0}_getControlKeyState(i){let e=0;return i.shiftKey&&(e|=16),i.ctrlKey&&(i.code==="ControlRight"?e|=4:e|=8),i.altKey&&(i.code==="AltRight"?e|=1:e|=2),this._enhancedKeyCodes.has(i.code)&&(e|=256),e}evaluateKeyboardEvent(i,e){let t=this._getVirtualKeyCode(i),r=this._getScanCode(i),s=this._getUnicodeChar(i),o=e?1:0,a=this._getControlKeyState(i);return{type:0,cancel:!0,key:`\x1B[${t};${r};${s};${o};${a};1_`}}};var xt=class{constructor(i,e){this._coreService=i;this._optionsService=e}_getWin32InputMode(){return this._win32InputMode??=new Zi,this._win32InputMode}_getKittyKeyboard(){return this._kittyKeyboard??=new ei,this._kittyKeyboard}evaluateKeyDown(i){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(i,!0);let e=this._coreService.kittyKeyboard.flags;return this.useKitty?this._getKittyKeyboard().evaluate(i,e,i.repeat?2:1,ie&&this._optionsService.rawOptions.macOptionIsMeta):ln(i,this._coreService.decPrivateModes.applicationCursorKeys,ie,this._optionsService.rawOptions.macOptionIsMeta)}evaluateKeyUp(i){if(this.useWin32InputMode)return this._getWin32InputMode().evaluateKeyboardEvent(i,!1);let e=this._coreService.kittyKeyboard.flags;if(this.useKitty&&e&2)return this._getKittyKeyboard().evaluate(i,e,3,ie&&this._optionsService.rawOptions.macOptionIsMeta)}get useKitty(){let i=this._coreService.kittyKeyboard.flags;return!!(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard&&ei.shouldUseProtocol(i))}get useWin32InputMode(){return!!(this._optionsService.rawOptions.vtExtensions?.win32InputMode&&this._coreService.decPrivateModes.win32InputMode)}};xt=y([m(0,Y),m(1,R)],xt);var ps=class{constructor(...i){this._entries=new Map;for(let[e,t]of i)this.set(e,t)}set(i,e){let t=this._entries.get(i);return this._entries.set(i,e),t}forEach(i){for(let[e,t]of this._entries.entries())i(e,t)}has(i){return this._entries.has(i)}get(i){return this._entries.get(i)}},Ji=class{constructor(){this._services=new ps;this._services.set(Qe,this)}setService(i,e){this._services.set(i,e)}getService(i){return this._services.get(i)}createInstance(i,...e){let t=Hs(i).sort((o,a)=>o.index-a.index),r=[];for(let o of t){let a=this._services.get(o.id);if(!a)throw new Error(`[createInstance] ${i.name} depends on UNKNOWN service ${o.id._id}.`);r.push(a)}let s=t.length>0?t[0].index:e.length;if(e.length!==s)throw new Error(`[createInstance] First service dependency of ${i.name} at position ${s+1} conflicts with ${e.length} static arguments`);return new i(...e,...r)}};var uo={trace:0,debug:1,info:2,warn:3,error:4,off:5},fo="xterm.js: ",wt=class extends g{constructor(e){super();this._optionsService=e;this._logLevel=5;this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange("logLevel",()=>this._updateLogLevel()))}get logLevel(){return this._logLevel}_updateLogLevel(){this._logLevel=uo[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tthis._length)for(let t=this._length;t=e;s--)this._array[this._getCyclicIndex(s+r.length)]=this._array[this._getCyclicIndex(s)];for(let s=0;sthis._maxLength){let s=this._length+r.length-this._maxLength;this._startIndex+=s,this._length=this._maxLength,this.onTrimEmitter.fire(s)}else this._length+=r.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,r){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+r<0)throw new Error("Cannot shift elements in list beyond index 0");if(r>0){for(let o=t-1;o>=0;o--)this.set(e+o+r,this.get(e+o));let s=e+t+r-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;sthis._limit?(this._builder.reset(),!0):!1}toString(){return this._builder.toString()}};var U=Object.freeze(new ue),Qi=0,hn=new F,er=new ii,De=class n{constructor(i,e,t,r=!1){this._stringCache=i;this.isWrapped=r;this._combined={};this._extendedAttrs={};this._data=new Uint32Array(e*3);let s=t??F.fromCharData([0,"",1,0]);for(let o=0;o>22,e&2097152?this._combined[i].charCodeAt(this._combined[i].length-1):t]}set(i,e){this._invalidateStringCache(),this._data[i*3+1]=e[0],e[1].length>1?(this._combined[i]=e[1],this._data[i*3+0]=i|2097152|e[2]<<22):this._data[i*3+0]=e[1].charCodeAt(0)|e[2]<<22}getWidth(i){return this._data[i*3+0]>>22}hasWidth(i){return this._data[i*3+0]&12582912}getFg(i){return this._data[i*3+1]}getBg(i){return this._data[i*3+2]}hasContent(i){return this._data[i*3+0]&4194303}getCodePoint(i){let e=this._data[i*3+0];return e&2097152?this._combined[i].charCodeAt(this._combined[i].length-1):e&2097151}isCombined(i){return this._data[i*3+0]&2097152}getString(i){let e=this._data[i*3+0];return e&2097152?this._combined[i]:e&2097151?be(e&2097151):""}isProtected(i){return this._data[i*3+2]&536870912}loadCell(i,e){return Qi=i*3,e.content=this._data[Qi+0],e.fg=this._data[Qi+1],e.bg=this._data[Qi+2],e.content&2097152?e.combinedData=this._combined[i]:e.combinedData="",e.bg&268435456?e.extended=this._extendedAttrs[i]:e.extended=U.extended.clone(),e}setCell(i,e){this._invalidateStringCache(),e.content&2097152&&(this._combined[i]=e.combinedData),e.bg&268435456&&(this._extendedAttrs[i]=e.extended),this._data[i*3+0]=e.content,this._data[i*3+1]=e.fg,this._data[i*3+2]=e.bg}setCellFromCodepoint(i,e,t,r){this._invalidateStringCache(),r.bg&268435456&&(this._extendedAttrs[i]=r.extended),this._data[i*3+0]=e|t<<22,this._data[i*3+1]=r.fg,this._data[i*3+2]=r.bg}addCodepointToCell(i,e,t){this._invalidateStringCache();let r=this._data[i*3+0];r&2097152?this._combined[i]+=be(e):r&2097151?(this._combined[i]=be(r&2097151)+be(e),r&=-2097152,r|=2097152):r=e|1<<22,t&&(r&=-12582913,r|=t<<22),this._data[i*3+0]=r}insertCells(i,e,t){if(this._invalidateStringCache(),i%=this.length,i&&this.getWidth(i-1)===2&&this.setCellFromCodepoint(i-1,0,1,t),e=0;--r)this.setCell(i+e+r,this.loadCell(i+r,hn));for(let r=0;rthis.length){if(this._data.buffer.byteLength>=t*4)this._data=new Uint32Array(this._data.buffer,0,t);else{let r=new Uint32Array(t);r.set(this._data),this._data=r}for(let r=this.length;r=i&&delete this._combined[a]}let s=Object.keys(this._extendedAttrs);for(let o=0;o=i&&delete this._extendedAttrs[a]}}return this.length=i,t*4*2=0;--i)if(this._data[i*3+0]&4194303)return i+(this._data[i*3+0]>>22);return 0}getNoBgTrimmedLength(){for(let i=this.length-1;i>=0;--i)if(this._data[i*3+0]&4194303||this._data[i*3+2]&50331648)return i+(this._data[i*3+0]>>22);return 0}copyCellsFrom(i,e,t,r,s){this._invalidateStringCache();let o=i._data;if(s)for(let a=r-1;a>=0;a--){for(let l=0;l<3;l++)this._data[(t+a)*3+l]=o[(e+a)*3+l];this._copyCellMapsFrom(i,e+a,t+a)}else for(let a=0;a>22||1}r&&r.push(e);let a=er.toString();if(er.reset(),s){let l=this._getStringCacheEntry(!0);l.value=a,l.isTrimmed=!!i}return a}_getStringCacheEntry(i){let e=this._stringCacheEntryRef?.deref();if(e&&e.generation===this._stringCache.generation)return e;if(!i)return;let t=this._stringCache.allocateEntry();return this._stringCacheEntryRef=new WeakRef(t),t}_invalidateStringCache(){let i=this._getStringCacheEntry(!1);i&&(i.value=void 0,i.isTrimmed=!1)}_copyCellMapsFrom(i,e,t){let r=e*3;i._data[r+0]&2097152&&(this._combined[t]=i._combined[e]),i._data[r+2]&268435456&&(this._extendedAttrs[t]=i._extendedAttrs[e])}_copySparseMapsFrom(i){this._combined={},this._extendedAttrs={};for(let e=0;ethis.entries.clear()))}touch(){this._scheduleClear()}allocateEntry(){let e={value:void 0,isTrimmed:!1,generation:this.generation};return this.entries.add(e),this._scheduleClear(),e}clear(){this._clearTimeout.clear(),this._lastAccessTimestamp=0,this.generation++;for(let e of this.entries)e.value=void 0,e.isTrimmed=!1;this.entries.clear()}_scheduleClear(){this._lastAccessTimestamp=Date.now(),!this._clearTimeout.value&&this._scheduleClearTimeout(15e3)}_scheduleClearTimeout(e){this._clearTimeout.value=Gs(()=>{let t=Date.now()-this._lastAccessTimestamp;if(t>=15e3){this.clear();return}this._scheduleClearTimeout(15e3-t)},e)}};function dn(n,i,e,t,r,s){let o=[];for(let a=0;a=a&&t0&&(f>c||d[f].getTrimmedLength()===0);f--)v++;v>0&&(o.push(a+d.length-v),o.push(v)),a+=d.length-1}return o}function un(n,i){let e=[],t=0,r=i[t],s=0;for(let o=0;ol&&(s-=l,o++);let h=n[o].getWidth(s-1)===2;h&&s--;let d=h?e-1:e;t.push(d),a+=d}return t}function Tt(n,i,e){if(i===n.length-1)return n[i].getTrimmedLength();let t=!n[i].hasContent(e-1)&&n[i].getWidth(e-1)===1,r=n[i+1].getWidth(0)===2;return t&&r?e-1:e}var rr=class rr{constructor(i){this.line=i;this.isDisposed=!1;this._disposables=[];this._id=rr._nextId++;this._onDispose=this.register(new b);this.onDispose=this._onDispose.event}get id(){return this._id}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),Oe(this._disposables),this._disposables.length=0)}register(i){return this._disposables.push(i),i}};rr._nextId=1;var ir=rr;var q={},Re=q.B;q[0]={"`":"\u25C6",a:"\u2592",b:"\u2409",c:"\u240C",d:"\u240D",e:"\u240A",f:"\xB0",g:"\xB1",h:"\u2424",i:"\u240B",j:"\u2518",k:"\u2510",l:"\u250C",m:"\u2514",n:"\u253C",o:"\u23BA",p:"\u23BB",q:"\u2500",r:"\u23BC",s:"\u23BD",t:"\u251C",u:"\u2524",v:"\u2534",w:"\u252C",x:"\u2502",y:"\u2264",z:"\u2265","{":"\u03C0","|":"\u2260","}":"\xA3","~":"\xB7"};q.A={"#":"\xA3"};q.B=void 0;q[4]={"#":"\xA3","@":"\xBE","[":"ij","\\":"\xBD","]":"|","{":"\xA8","|":"f","}":"\xBC","~":"\xB4"};q.C=q[5]={"[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};q.R={"#":"\xA3","@":"\xE0","[":"\xB0","\\":"\xE7","]":"\xA7","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xA8"};q.Q={"@":"\xE0","[":"\xE2","\\":"\xE7","]":"\xEA","^":"\xEE","`":"\xF4","{":"\xE9","|":"\xF9","}":"\xE8","~":"\xFB"};q.K={"@":"\xA7","[":"\xC4","\\":"\xD6","]":"\xDC","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xDF"};q.Y={"#":"\xA3","@":"\xA7","[":"\xB0","\\":"\xE7","]":"\xE9","`":"\xF9","{":"\xE0","|":"\xF2","}":"\xE8","~":"\xEC"};q.E=q[6]={"@":"\xC4","[":"\xC6","\\":"\xD8","]":"\xC5","^":"\xDC","`":"\xE4","{":"\xE6","|":"\xF8","}":"\xE5","~":"\xFC"};q.Z={"#":"\xA3","@":"\xA7","[":"\xA1","\\":"\xD1","]":"\xBF","{":"\xB0","|":"\xF1","}":"\xE7"};q.H=q[7]={"@":"\xC9","[":"\xC4","\\":"\xD6","]":"\xC5","^":"\xDC","`":"\xE9","{":"\xE4","|":"\xF6","}":"\xE5","~":"\xFC"};q["="]={"#":"\xF9","@":"\xE0","[":"\xE9","\\":"\xE7","]":"\xEA","^":"\xEE",_:"\xE8","`":"\xF4","{":"\xE4","|":"\xF6","}":"\xFC","~":"\xFB"};var pn=4294967295,si=class extends g{constructor(e,t,r,s){super();this._hasScrollback=e;this._optionsService=t;this._bufferService=r;this._logService=s;this.ydisp=0;this.ybase=0;this.y=0;this.x=0;this.tabs={};this.savedY=0;this.savedX=0;this.savedCurAttrData=U.clone();this.savedCharset=Re;this.savedCharsets=[];this.savedGlevel=0;this.savedOriginMode=!1;this.savedWraparoundMode=!0;this.markers=[];this._nullCell=F.fromCharData([0,"",1,0]);this._whitespaceCell=F.fromCharData([0," ",1,32]);this._isClearing=!1;this._memoryCleanupPosition=0;this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new ti(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops(),this._memoryCleanupQueue=new It(this._logService),this._register(E(()=>this._memoryCleanupQueue.clear())),this._register(E(()=>this.clearAllMarkers())),this._stringCache=this._register(new tr)}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new ke),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new ke),this._whitespaceCell}getBlankLine(e,t){return new De(this._stringCache,this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){let t=this.ybase+this.y-this.ydisp;return t>=0&&tpn?pn:t}fillViewportRows(e){if(this.lines.length===0){e??=U;let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this._stringCache.clear(),this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new ti(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){let r=this.getNullCell(U);this._stringCache.clear();let s=0,o=this._getCorrectBufferLength(t);if(o>this.lines.maxLength&&(this.lines.maxLength=o),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+a+1?(this.ybase--,a++,this.ydisp>0&&this.ydisp--):this.lines.push(new De(this._stringCache,e,r,!1)));else for(let l=this._rows;l>t;l--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(o0&&(this.lines.trimStart(l),this.ybase=Math.max(this.ybase-l,0),this.ydisp=Math.max(this.ydisp-l,0),this.savedY=Math.max(this.savedY-l,0)),this.lines.maxLength=o}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),a&&(this.y+=a),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let a=0;a0){let a=Math.max(0,this.lines.length-this.ybase-1);this.y=Math.min(this.y,a)}this._memoryCleanupQueue.clear(),s>.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){let e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&e.backend==="conpty"&&e.buildNumber>=21376:this._hasScrollback}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){let r=this._optionsService.rawOptions.reflowCursorLine,s=dn(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(U),r);if(s.length>0){let o=un(this.lines,s);fn(this.lines,o.layout),this._reflowLargerAdjustViewport(e,t,o.countRemoved)}}_reflowLargerAdjustViewport(e,t,r){let s=this.getNullCell(U),o=r;for(;o-- >0;)this.ybase===0?(this.y>0&&this.y--,this.lines.length=0;l--){let h=this.lines.get(l);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;let d=[h];for(;h.isWrapped&&l>0;)h=this.lines.get(--l),d.unshift(h);if(!r){let T=this.ybase+this.y;if(T>=l&&T0&&(o.push({start:l+d.length+a,newLines:v}),a+=v.length),d.push(...v);let f=u.length-1,S=u[f];S===0&&(f--,S=u[f]);let I=d.length-_-1,w=c;for(;I>=0;){let T=Math.min(w,S);if(d[f]===void 0)break;if(d[f].copyCellsFrom(d[I],w-T,S-T,T,!0),S-=T,S===0&&(f--,S=u[f]),w-=T,w===0){I--;let te=Math.max(I,0);w=Tt(d,te,this._cols)}}for(let T=0;T0;)this.ybase===0?this.y0){let l=[],h=[];for(let S=0;S=0;S--)if(_&&_.start>c+p){for(let I=_.newLines.length-1;I>=0;I--)this.lines.set(S--,_.newLines[I]);S++,l.push({index:c+1,amount:_.newLines.length}),p+=_.newLines.length,_=o[++u]}else this.lines.set(S,h[c--]);let v=0;for(let S=l.length-1;S>=0;S--)l[S].index+=v,this.lines.onInsertEmitter.fire(l[S]),v+=l[S].amount;let f=Math.max(0,d+a-this.lines.maxLength);f>0&&this.lines.onTrimEmitter.fire(f)}}translateBufferLineToString(e,t,r=0,s){let o=this.lines.get(e);return o?o.translateToString(t,r,s):""}getWrappedRangeForLine(e){let t=e,r=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;r+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=r,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(r=>{t.line>=r.index&&(t.line+=r.amount)})),t.register(this.lines.onDelete(r=>{t.line>=r.index&&t.liner.index&&(t.line-=r.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}};var sr=class extends g{constructor(e,t,r){super();this._optionsService=e;this._bufferService=t;this._logService=r;this._normalBuffer=this._register(new P);this._altBuffer=this._register(new P);this._onBufferActivate=this._register(new b);this.onBufferActivate=this._onBufferActivate.event;this.reset(),this._register(this._optionsService.onSpecificOptionChange("scrollback",()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange("tabStopWidth",()=>this.setupTabStops()))}reset(){this._normal=new si(!0,this._optionsService,this._bufferService,this._logService),this._normalBuffer.value=this._normal,this._normal.fillViewportRows(),this._alt=new si(!1,this._optionsService,this._bufferService,this._logService),this._altBuffer.value=this._alt,this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}};var Dt=class extends g{constructor(e,t){super();this.isUserScrolling=!1;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;this.cols=Math.max(e.rawOptions.cols||0,2),this.rows=Math.max(e.rawOptions.rows||0,1),this.buffers=this._register(new sr(e,this,t)),this._register(this.buffers.onBufferActivate(r=>{this._onScroll.fire(r.activeBuffer.ydisp)}))}get buffer(){return this.buffers.active}resize(e,t){let r=this.cols!==e,s=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:r,rowsChanged:s})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){let r=this.buffer,s;s=this._cachedBlankLine,(!s||s.length!==this.cols||s.getFg(0)!==e.fg||s.getBg(0)!==e.bg)&&(s=r.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;let o=r.ybase+r.scrollTop,a=r.ybase+r.scrollBottom;if(r.scrollTop===0){let l=r.lines.isFull;a===r.lines.length-1?l?r.lines.recycle().copyFrom(s):r.lines.push(s.clone()):r.lines.splice(a+1,0,s.clone()),l?this.isUserScrolling&&(r.ydisp=Math.max(r.ydisp-1,0)):(r.ybase++,this.isUserScrolling||r.ydisp++)}else{let l=a-o+1;r.lines.shiftElements(o+1,l-1,-1),r.lines.set(a,s.clone())}this.isUserScrolling||(r.ydisp=r.ybase),this._onScroll.fire(r.ydisp)}scrollLines(e,t){let r=this.buffer;if(e<0){if(r.ydisp===0)return;this.isUserScrolling=!0}else e+r.ydisp>=r.ybase&&(this.isUserScrolling=!1);let s=r.ydisp;r.ydisp=Math.max(Math.min(r.ydisp+e,r.ybase),0),s!==r.ydisp&&(t||this._onScroll.fire(r.ydisp))}};Dt=y([m(0,R),m(1,fe)],Dt);var Rt={cols:80,rows:24,showCursorImmediately:!1,cursorBlink:!1,blinkIntervalDuration:0,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollSensitivity:5,fontFamily:"monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollbar:{showScrollbar:!0},scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,mouseEventsRequireAlt:!1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:ie,windowOptions:{},windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",quirks:{},vtExtensions:{}},po=["normal","bold","100","200","300","400","500","600","700","800","900"],nr=class extends g{constructor(e){super();this._onOptionChange=this._register(new b);this.onOptionChange=this._onOptionChange.event;let t={...Rt};for(let r in e)if(r in t)try{let s=e[r];t[r]=this._sanitizeAndValidateOption(r,s)}catch(s){console.error(s)}this.rawOptions=t,this.options={...t},this._setupOptions(),this._register(E(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(r=>{r===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(r=>{e.indexOf(r)!==-1&&t()})}_setupOptions(){let e=r=>{if(!(r in Rt))throw new Error(`No option with key "${r}"`);return this.rawOptions[r]},t=(r,s)=>{if(!(r in Rt))throw new Error(`No option with key "${r}"`);s=this._sanitizeAndValidateOption(r,s),this.rawOptions[r]!==s&&(this.rawOptions[r]=s,this._onOptionChange.fire(r))};for(let r in this.rawOptions){let s={get:e.bind(this,r),set:t.bind(this,r)};Object.defineProperty(this.options,r,s)}}_sanitizeAndValidateOption(e,t){switch(e){case"cursorStyle":if(t||(t=Rt[e]),!mo(t))throw new Error(`"${t}" is not a valid value for ${e}`);break;case"wordSeparator":t||(t=Rt[e]);break;case"fontWeight":case"fontWeightBold":if(typeof t=="number"&&1<=t&&t<=1e3)break;t=po.includes(t)?t:Rt[e];break;case"blinkIntervalDuration":if(t=Math.floor(t),t<0)throw new Error(`${e} cannot be less than 0, value: ${t}`);break;case"cursorWidth":t=Math.floor(t);case"lineHeight":case"tabStopWidth":if(t<1)throw new Error(`${e} cannot be less than 1, value: ${t}`);break;case"minimumContrastRatio":t=Math.max(1,Math.min(21,Math.round(t*10)/10));break;case"scrollback":if(t=Math.min(t,4294967295),t<0)throw new Error(`${e} cannot be less than 0, value: ${t}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(t<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${t}`);break;case"rows":case"cols":if(!t&&t!==0)throw new Error(`${e} must be numeric, value: ${t}`);break;case"windowsPty":t=t??{};break}return t}};function mo(n){return n==="block"||n==="underline"||n==="bar"}var mn=Object.freeze({insertMode:!1}),bn=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,colorSchemeUpdates:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,win32InputMode:!1,wraparound:!0}),vn=()=>({flags:0,mainFlags:0,altFlags:0,mainStack:[],altStack:[]}),Lt=class extends g{constructor(e,t,r){super();this._bufferService=e;this._logService=t;this._optionsService=r;this.isCursorHidden=!1;this._onData=this._register(new b);this.onData=this._onData.event;this._onUserInput=this._register(new b);this.onUserInput=this._onUserInput.event;this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onRequestScrollToBottom=this._register(new b);this.onRequestScrollToBottom=this._onRequestScrollToBottom.event;this.isCursorInitialized=r.rawOptions.showCursorImmediately??!1,this.modes=structuredClone(mn),this.decPrivateModes=structuredClone(bn),this.kittyKeyboard=vn()}reset(){this.modes=structuredClone(mn),this.decPrivateModes=structuredClone(bn),this.kittyKeyboard=vn()}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;let r=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&r.ybase!==r.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace("sending data (codes)",()=>e.split("").map(s=>s.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace("sending binary (codes)",()=>e.split("").map(t=>t.charCodeAt(0))),this._onBinary.fire(e))}};Lt=y([m(0,D),m(1,fe),m(2,R)],Lt);var Sn={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:n=>n.button===4||n.action!==1?!1:(n.ctrl=!1,n.alt=!1,n.shift=!1,!0)},VT200:{events:19,restrict:n=>n.action!==32},DRAG:{events:23,restrict:n=>!(n.action===32&&n.button===3)},ANY:{events:31,restrict:n=>!0}};function vs(n,i){let e=(n.ctrl?16:0)|(n.shift?4:0)|(n.alt?8:0);return n.button===4?(e|=64,e|=n.action):(e|=n.button&3,n.button&4&&(e|=64),n.button&8&&(e|=128),n.action===32?e|=32:n.action===0&&!i&&(e|=3)),e}var Ss=String.fromCharCode,gn={DEFAULT:n=>{let i=[vs(n,!1)+32,n.col+32,n.row+32];return i[0]>255||i[1]>255||i[2]>255?"":`\x1B[M${Ss(i[0])}${Ss(i[1])}${Ss(i[2])}`},SGR:n=>{let i=n.action===0&&n.button!==4?"m":"M";return`\x1B[<${vs(n,!0)};${n.col};${n.row}${i}`},SGR_PIXELS:n=>{let i=n.action===0&&n.button!==4?"m":"M";return`\x1B[<${vs(n,!0)};${n.x};${n.y}${i}`}},or=class extends g{constructor(){super();this._protocols={};this._encodings={};this._activeProtocol="";this._activeEncoding="";this._onProtocolChange=this._register(new b);this.onProtocolChange=this._onProtocolChange.event;for(let e of Object.keys(Sn))this.addProtocol(e,Sn[e]);for(let e of Object.keys(gn))this.addEncoding(e,gn[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return this._protocols[this._activeProtocol].events!==0}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT"}setCustomWheelEventHandler(e){this._customWheelEventHandler=e}allowCustomWheelEvent(e){return this._customWheelEventHandler?this._customWheelEventHandler(e)!==!1:!0}restrictMouseEvent(e){return this._protocols[this._activeProtocol].restrict(e)}encodeMouseEvent(e){return this._encodings[this._activeEncoding](e)}get isDefaultEncoding(){return this._activeEncoding==="DEFAULT"}get isPixelEncoding(){return this._activeEncoding==="SGR_PIXELS"}};var me=class n{constructor(){this._providers=Object.create(null);this._active="";this._onChange=new b;this.onChange=this._onChange.event}static extractShouldJoin(i){return(i&1)!==0}static extractWidth(i){return i>>1&3}static extractCharKind(i){return i>>3}static createPropertyValue(i,e,t=!1){return(i&16777215)<<3|(e&3)<<1|(t?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(i){if(!this._providers[i])throw new Error(`unknown Unicode version "${i}"`);this._active=i,this._activeProvider=this._providers[i],this._onChange.fire(i)}register(i){this._providers[i.version]=i,this._active||(this.activeVersion=i.version)}wcwidth(i){return this._activeProvider.wcwidth(i)}getStringCellWidth(i){let e=0,t=0,r=i.length;for(let s=0;s=r)return e+this.wcwidth(o);let h=i.charCodeAt(s);56320<=h&&h<=57343?o=(o-55296)*1024+h-56320+65536:e+=this.wcwidth(h)}let a=this.charProperties(o,t),l=n.extractWidth(a);n.extractShouldJoin(a)&&(l-=n.extractWidth(t)),e+=l,t=a}return e}charProperties(i,e){return this._activeProvider.charProperties(i,e)}};var gs=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],bo=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]],X;function vo(n,i){let e=0,t=i.length-1,r;if(ni[t][1])return!1;for(;t>=e;)if(r=e+t>>1,n>i[r][1])e=r+1;else if(n=131072&&i<=196605||i>=196608&&i<=262141?2:1}charProperties(i,e){let t=this.wcwidth(i),r=t===0&&e!==0;if(r){let s=me.extractWidth(e);s===0?r=!1:s>t&&(t=s)}return me.createPropertyValue(0,t,r)}};var lr=class{constructor(){this.glevel=0;this._charsets=[]}get charsets(){return this._charsets}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(i){this.glevel=i,this.charset=this._charsets[i]}setgCharset(i,e){this._charsets[i]=e,this.glevel===i&&(this.charset=e)}};function Is(n){let e=n.buffer.lines.get(n.buffer.ybase+n.buffer.y-1)?.get(n.cols-1),t=n.buffer.lines.get(n.buffer.ybase+n.buffer.y);t&&e&&(t.isWrapped=e[3]!==0&&e[3]!==32)}var At=class n{constructor(i=32,e=32){this.maxLength=i;this.maxSubParamsLength=e;if(e>256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(i),this.length=0,this._subParams=new Int32Array(e),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(i),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}static fromArray(i){let e=new n;if(!i.length)return e;for(let t=Array.isArray(i[0])?1:0;t>8,r=this._subParamsIdx[e]&255;r-t>0&&i.push(Array.prototype.slice.call(this._subParams,t,r))}return i}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}resetZdm(){this.length=1,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1,this._subParamsIdx[0]=0,this.params[0]=0}addParam(i){if(this._digitIsSub=!1,this.length>=this.maxLength){this._rejectDigits=!0;return}if(i<-1)throw new Error("values less than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=i>2147483647?2147483647:i}addSubParam(i){if(this._digitIsSub=!0,!!this.length){if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength){this._rejectSubDigits=!0;return}if(i<-1)throw new Error("values less than -1 are not allowed");this._subParams[this._subParamsLength++]=i>2147483647?2147483647:i,this._subParamsIdx[this.length-1]++}}hasSubParams(i){return(this._subParamsIdx[i]&255)-(this._subParamsIdx[i]>>8)>0}getSubParams(i){let e=this._subParamsIdx[i]>>8,t=this._subParamsIdx[i]&255;return t-e>0?this._subParams.subarray(e,t):null}getSubParamsAll(){let i={};for(let e=0;e>8,r=this._subParamsIdx[e]&255;r-t>0&&(i[e]=this._subParams.slice(t,r))}return i}addDigit(i){let e;if(this._rejectDigits||!(e=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;let t=this._digitIsSub?this._subParams:this.params,r=t[e-1];t[e-1]=~r?Math.min(r*10+i,2147483647):i}};var ni=[],cr=class{constructor(){this._state=0;this._active=ni;this._id=-1;this._handlers=Object.create(null);this._handlerFb=()=>{};this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(i,e){this._handlers[i]??=[];let t=this._handlers[i];return t.push(e),{dispose:()=>{let r=t.indexOf(e);r!==-1&&t.splice(r,1)}}}clearHandler(i){this._handlers[i]&&delete this._handlers[i]}setHandlerFallback(i){this._handlerFb=i}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=ni}reset(){if(this._state===2)for(let i=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;i>=0;--i)this._active[i].end(!1);this._stack.paused=!1,this._active=ni,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||ni,!this._active.length)this._handlerFb(this._id,"START");else for(let i=this._active.length-1;i>=0;i--)this._active[i].start()}_put(i,e,t){if(!this._active.length)this._handlerFb(this._id,"PUT",ye(i,e,t));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(i,e,t)}start(){this.reset(),this._state=1}put(i,e,t){if(this._state!==3){if(this._state===1)for(;e0&&this._put(i,e,t)}}end(i,e=!0){if(this._state!==0){if(this._state!==3)if(this._state===1&&this._start(),!this._active.length)this._handlerFb(this._id,"END",i);else{let t=!1,r=this._active.length-1,s=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,t=e,s=this._stack.fallThrough,this._stack.paused=!1),!s&&t===!1){for(;r>=0&&(t=this._active[r].end(i),t!==!0);r--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,t;r--}for(;r>=0;r--)if(t=this._active[r].end(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,t}this._active=ni,this._id=-1,this._state=0}}},hr=class hr{constructor(i){this._handler=i;this._data=new We(hr._payloadLimit);this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(i,e,t){this._hitLimit||this._data.append(ye(i,e,t))&&(this._hitLimit=!0)}end(i){let e=!1;if(this._hitLimit)e=!1;else if(i&&(e=this._handler(this._data.toString()),e instanceof Promise))return e.then(t=>(this._data.reset(),this._hitLimit=!1,t));return this._data.reset(),this._hitLimit=!1,e}};hr._payloadLimit=1e7;var ne=hr;var oi=[],dr=class{constructor(){this._handlers=Object.create(null);this._active=oi;this._ident=0;this._handlerFb=()=>{};this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=oi}registerHandler(i,e){this._handlers[i]??=[];let t=this._handlers[i];return t.push(e),{dispose:()=>{let r=t.indexOf(e);r!==-1&&t.splice(r,1)}}}clearHandler(i){this._handlers[i]&&delete this._handlers[i]}setHandlerFallback(i){this._handlerFb=i}reset(){if(this._active.length)for(let i=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;i>=0;--i)this._active[i].unhook(!1);this._stack.paused=!1,this._active=oi,this._ident=0}hook(i,e){if(this.reset(),this._ident=i,this._active=this._handlers[i]||oi,!this._active.length)this._handlerFb(this._ident,"HOOK",e);else for(let t=this._active.length-1;t>=0;t--)this._active[t].hook(e)}put(i,e,t){if(!this._active.length)this._handlerFb(this._ident,"PUT",ye(i,e,t));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(i,e,t)}unhook(i,e=!0){if(!this._active.length)this._handlerFb(this._ident,"UNHOOK",i);else{let t=!1,r=this._active.length-1,s=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,t=e,s=this._stack.fallThrough,this._stack.paused=!1),!s&&t===!1){for(;r>=0&&(t=this._active[r].unhook(i),t!==!0);r--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,t;r--}for(;r>=0;r--)if(t=this._active[r].unhook(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,t}this._active=oi,this._ident=0}},ai=new At;ai.addParam(0);var ur=class ur{constructor(i){this._handler=i;this._data=new We(ur._payloadLimit);this._params=ai;this._hitLimit=!1}hook(i){this._params=i.length>1||i.params[0]?i.clone():ai,this._data.reset(),this._hitLimit=!1}put(i,e,t){this._hitLimit||this._data.append(ye(i,e,t))&&(this._hitLimit=!0)}unhook(i){let e=!1;if(this._hitLimit)e=!1;else if(i&&(e=this._handler(this._data.toString(),this._params),e instanceof Promise))return e.then(t=>(this._params=ai,this._data.reset(),this._hitLimit=!1,t));return this._params=ai,this._data.reset(),this._hitLimit=!1,e}};ur._payloadLimit=1e7;var li=ur;var ci=[],fr=class{constructor(){this._handlers=Object.create(null);this._active=ci;this._ident=0;this._handlerFb=()=>{};this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(i,e){this._handlers[i]??=[];let t=this._handlers[i];return t.push(e),{dispose:()=>{let r=t.indexOf(e);r!==-1&&t.splice(r,1)}}}clearHandler(i){this._handlers[i]&&delete this._handlers[i]}setHandlerFallback(i){this._handlerFb=i}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=ci}reset(){if(this._active.length)for(let i=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;i>=0;--i)this._active[i].end(!1);this._stack.paused=!1,this._active=ci,this._ident=0}start(i){if(this.reset(),this._ident=i,this._active=this._handlers[i]||ci,!this._active.length)this._handlerFb(this._ident,"START");else for(let e=this._active.length-1;e>=0;e--)this._active[e].start()}put(i,e,t){if(!this._active.length)this._handlerFb(this._ident,"PUT",ye(i,e,t));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(i,e,t)}end(i,e=!0){if(!this._active.length)this._handlerFb(this._ident,"END",i);else{let t=!1,r=this._active.length-1,s=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,t=e,s=this._stack.fallThrough,this._stack.paused=!1),!s&&t===!1){for(;r>=0&&(t=this._active[r].end(i),t!==!0);r--)if(t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,t;r--}for(;r>=0;r--)if(t=this._active[r].end(!1),t instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,t}this._active=ci,this._ident=0}},pr=class pr{constructor(i){this._handler=i;this._data=new We(pr._payloadLimit);this._hitLimit=!1}start(){this._data.reset(),this._hitLimit=!1}put(i,e,t){this._hitLimit||this._data.append(ye(i,e,t))&&(this._hitLimit=!0)}end(i){let e=!1;if(this._hitLimit)e=!1;else if(i&&(e=this._handler(this._data.toString()),e instanceof Promise))return e.then(t=>(this._data.reset(),this._hitLimit=!1,t));return this._data.reset(),this._hitLimit=!1,e}};pr._payloadLimit=1e7;var _r=pr;var Cs=class{constructor(i){this.table=new Uint16Array(i)}setDefault(i,e){this.table.fill(i<<8|e)}add(i,e,t,r){this.table[e<<8|i]=t<<8|r}addMany(i,e,t,r){for(let s=0;sl),t=(a,l)=>e.slice(a,l),r=t(32,127),s=t(0,24);s.push(25),s.push.apply(s,t(28,32));let o=t(0,17);n.setDefault(1,0),n.addMany(r,0,2,0);for(let a of o)n.addMany([24,26,153,154],a,3,0),n.addMany(t(128,144),a,3,0),n.addMany(t(144,152),a,3,0),n.add(156,a,0,0),n.add(27,a,11,1),n.add(157,a,4,8),n.addMany([152,158],a,0,7),n.add(159,a,11,14),n.add(155,a,11,3),n.add(144,a,11,9);return n.addMany(s,0,3,0),n.addMany(s,1,3,1),n.add(127,1,0,1),n.addMany(s,8,0,8),n.addMany(s,3,3,3),n.add(127,3,0,3),n.addMany(s,4,3,4),n.add(127,4,0,4),n.addMany(s,6,3,6),n.addMany(s,5,3,5),n.add(127,5,0,5),n.addMany(s,2,3,2),n.add(127,2,0,2),n.add(93,1,4,8),n.addMany(r,8,5,8),n.add(127,8,5,8),n.addMany([156,27,24,26,7],8,6,0),n.addMany(t(28,32),8,0,8),n.addMany([88,94],1,0,7),n.addMany(r,7,0,7),n.addMany(s,7,0,7),n.add(156,7,0,0),n.add(127,7,0,7),n.add(95,1,11,14),n.addMany(s,14,0,14),n.add(127,14,0,14),n.addMany(t(32,48),14,9,15),n.addMany(t(48,127),14,15,16),n.addMany(t(48,127),15,15,16),n.addMany(s,15,0,15),n.addMany(t(32,48),15,9,15),n.add(127,15,0,15),n.addMany(r,16,16,16),n.addMany(s,16,0,16),n.addMany(t(8,14),16,16,16),n.add(127,16,0,16),n.addMany([27,156,24,26],16,17,0),n.add(91,1,11,3),n.addMany(t(64,127),3,7,0),n.addMany(t(48,60),3,8,4),n.addMany([60,61,62,63],3,9,4),n.addMany(t(48,60),4,8,4),n.addMany(t(64,127),4,7,0),n.addMany([60,61,62,63],4,0,6),n.addMany(t(32,64),6,0,6),n.add(127,6,0,6),n.addMany(t(64,127),6,0,0),n.addMany(t(32,48),3,9,5),n.addMany(t(32,48),5,9,5),n.addMany(t(48,64),5,0,6),n.addMany(t(64,127),5,7,0),n.addMany(t(32,48),4,9,5),n.addMany(t(32,48),1,9,2),n.addMany(t(32,48),2,9,2),n.addMany(t(48,127),2,10,0),n.addMany(t(48,80),1,10,0),n.addMany(t(81,88),1,10,0),n.addMany([89,90,92],1,10,0),n.addMany(t(96,127),1,10,0),n.add(80,1,11,9),n.addMany(s,9,0,9),n.add(127,9,0,9),n.addMany(t(32,48),9,9,12),n.addMany(t(48,60),9,8,10),n.addMany([60,61,62,63],9,9,10),n.addMany(s,11,0,11),n.addMany(t(32,128),11,0,11),n.addMany(s,10,0,10),n.add(127,10,0,10),n.addMany(t(48,60),10,8,10),n.addMany([60,61,62,63],10,0,11),n.addMany(t(32,48),10,9,12),n.addMany(s,12,0,12),n.add(127,12,0,12),n.addMany(t(32,48),12,9,12),n.addMany(t(48,64),12,0,11),n.addMany(t(64,127),12,12,13),n.addMany(t(64,127),10,12,13),n.addMany(t(64,127),9,12,13),n.addMany(s,13,13,13),n.addMany(r,13,13,13),n.add(127,13,0,13),n.addMany([27,156,24,26],13,14,0),n.add(oe,0,2,0),n.add(oe,8,5,8),n.add(oe,6,0,6),n.add(oe,11,0,11),n.add(oe,13,13,13),n.add(oe,16,16,16),n})(),mr=class extends g{constructor(e=So){super();this._transitions=e;this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0};this.initialState=0,this.currentState=this.initialState,this._params=new At,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(t,r,s)=>{},this._executeHandlerFb=t=>{},this._csiHandlerFb=(t,r)=>{},this._escHandlerFb=t=>{},this._errorHandlerFb=t=>t,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register(E(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._executeHandlersArr=new Array(24).fill(void 0),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new cr),this._dcsParser=this._register(new dr),this._apcParser=this._register(new fr),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},()=>!0)}_identifier(e,t=[64,126]){let r=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(r=e.prefix.charCodeAt(0),r<60||r>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let o=0;oa||a>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");r<<=8,r|=a}}if(e.final.length!==1)throw new Error("final must be a single byte");let s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return r<<=8,r|=s,r}identToString(e){let t=[];for(;e;)t.push(String.fromCharCode(e&255)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){let r=this._identifier(e,[48,126]);this._escHandlers[r]??=[];let s=this._escHandlers[r];return s.push(t),{dispose:()=>{let o=s.indexOf(t);o!==-1&&s.splice(o,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){let r=e.charCodeAt(0);this._executeHandlers[r]=t,r<24&&(this._executeHandlersArr[r]=t)}clearExecuteHandler(e){let t=e.charCodeAt(0);this._executeHandlers[t]&&delete this._executeHandlers[t],t<24&&(this._executeHandlersArr[t]=void 0)}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){let r=this._identifier(e);this._csiHandlers[r]??=[];let s=this._csiHandlers[r];return s.push(t),{dispose:()=>{let o=s.indexOf(t);o!==-1&&s.splice(o,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}registerApcHandler(e,t){return e.prefix=void 0,this._apcParser.registerHandler(this._identifier(e,[48,126]),t)}clearApcHandler(e){e.prefix=void 0,this._apcParser.clearHandler(this._identifier(e,[48,126]))}setApcHandlerFallback(e){this._apcParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._apcParser.reset(),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0,this._parseStack.state!==0&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,r,s,o){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=r,this._parseStack.transition=s,this._parseStack.chunkPos=o}parse(e,t,r){let s,o,a=0,l;if(this._parseStack.state)if(this._parseStack.state===2)this._parseStack.state=0,a=this._parseStack.chunkPos+1;else{if(r===void 0||this._parseStack.state===1)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");let h=this._parseStack.handlers,d=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(r===!1&&d>-1){for(;d>=0&&(l=h[d](this._params),l!==!0);d--)if(l instanceof Promise)return this._parseStack.handlerPos=d,l}this._parseStack.handlers=[];break;case 4:if(r===!1&&d>-1){for(;d>=0&&(l=h[d](),l!==!0);d--)if(l instanceof Promise)return this._parseStack.handlerPos=d,l}this._parseStack.handlers=[];break;case 6:if(s=e[this._parseStack.chunkPos],l=this._dcsParser.unhook(s!==24&&s!==26,r),l)return l;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 5:if(s=e[this._parseStack.chunkPos],l=this._oscParser.end(s!==24&&s!==26,r),l)return l;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break;case 7:if(s=e[this._parseStack.chunkPos],l=this._apcParser.end(s!==24&&s!==26,r),l)return l;s===27&&(this._parseStack.transition|=1),this._params.resetZdm(),this._collect=0;break}this._parseStack.state=0,a=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=this._parseStack.transition&255}for(let h=a;h=60&&c<=63&&(this._collect=c,d++);let u=!1;for(;d=48&&c<=57)this._params.addDigit(c-48);else if(c===59)this._params.addParam(0);else if(c===58)this._params.addSubParam(-1);else if(c>=64&&c<=126){let _=this._csiHandlers[this._collect<<8|c],p=_?_.length-1:-1;for(;p>=0&&(l=_[p](this._params),l!==!0);p--)if(l instanceof Promise)return o=1792,this._preserveStack(3,_,p,o,d),l;p<0&&this._csiHandlerFb(this._collect<<8|c,this._params),this.precedingJoinState=0,h=d,this.currentState=0,u=!0;break}else break;u||(h=d-1,this.currentState=4);continue}switch(o=this._transitions.table[this.currentState<<8|(s>8){case 2:let d=h,c=t-4;for(;d=32&&(e[d]<=126||e[d]>=oe)&&e[++d]>=32&&(e[d]<=126||e[d]>=oe)&&e[++d]>=32&&(e[d]<=126||e[d]>=oe)&&e[++d]>=32&&(e[d]<=126||e[d]>=oe););if(d>=c)for(;d=32&&(e[d]<=126||e[d]>=oe);)d++;this._printHandler(e,h,d),h=d-1;break;case 3:this._executeHandlers[s]?this._executeHandlers[s]():this._executeHandlerFb(s),this.precedingJoinState=0;break;case 0:break;case 1:if(this._errorHandler({position:h,code:s,currentState:this.currentState,collect:this._collect,params:this._params,abort:!1}).abort)return;break;case 7:let _=this._csiHandlers[this._collect<<8|s],p=_?_.length-1:-1;for(;p>=0&&(l=_[p](this._params),l!==!0);p--)if(l instanceof Promise)return this._preserveStack(3,_,p,o,h),l;p<0&&this._csiHandlerFb(this._collect<<8|s,this._params),this.precedingJoinState=0;break;case 8:do switch(s){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(s-48)}while(++h47&&s<60);h--;break;case 9:this._collect<<=8,this._collect|=s;break;case 10:let v=this._escHandlers[this._collect<<8|s],f=v?v.length-1:-1;for(;f>=0&&(l=v[f](),l!==!0);f--)if(l instanceof Promise)return this._preserveStack(4,v,f,o,h),l;f<0&&this._escHandlerFb(this._collect<<8|s),this.precedingJoinState=0;break;case 11:this._params.resetZdm(),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|s,this._params);break;case 13:for(let S=h+1;;++S)if(S>=t||(s=e[S])===24||s===26||s===27||s>127&&s=t||(s=e[S])<32||s>127&&s=32&&e[S]<127||e[S]>=8&&e[S]<14||e[S]>=oe))){this._apcParser.put(e,h,S),h=S-1;break}break;case 17:if(l=this._apcParser.end(s!==24&&s!==26),l)return this._preserveStack(7,[],0,o,h),l;s===27&&(o|=1),this._params.resetZdm(),this._collect=0,this.precedingJoinState=0;break}this.currentState=o&255}}};var go=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,Io=/^[\da-f]+$/;function ys(n){if(!n)return;let i=n.toLowerCase();if(i.startsWith("rgb:")){i=i.slice(4);let e=go.exec(i);if(e){let t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(i.startsWith("#")&&(i=i.slice(1),Io.exec(i)&&[3,6,9,12].includes(i.length))){let e=i.length/3,t=[0,0,0];for(let r=0;r<3;++r){let s=parseInt(i.slice(e*r,e*r+e),16);t[r]=e===1?s<<4:e===2?s:e===3?s>>4:s>>8}return t}}function Es(n,i){let e=n.toString(16),t=e.length<2?"0"+e:e;switch(i){case 4:return e[0];case 8:return t;case 12:return(t+t).slice(0,3);default:return t+t}}function En(n,i=16){let[e,t,r]=n;return`rgb:${Es(e,i)}/${Es(t,i)}/${Es(r,i)}`}var yn="6.1.0-beta.287";var Eo={"(":0,")":1,"*":2,"+":3,"-":1,".":2};function xn(n,i){if(n>24)return i.setWinLines||!1;switch(n){case 1:return!!i.restoreWin;case 2:return!!i.minimizeWin;case 3:return!!i.setWinPosition;case 4:return!!i.setWinSizePixels;case 5:return!!i.raiseWin;case 6:return!!i.lowerWin;case 7:return!!i.refreshWin;case 8:return!!i.setWinSizeChars;case 9:return!!i.maximizeWin;case 10:return!!i.fullscreenWin;case 11:return!!i.getWinState;case 13:return!!i.getWinPosition;case 14:return!!i.getWinSizePixels;case 15:return!!i.getScreenSizePixels;case 16:return!!i.getCellSizePixels;case 18:return!!i.getWinSizeChars;case 19:return!!i.getScreenSizeChars;case 20:return!!i.getIconTitle;case 21:return!!i.getWinTitle;case 22:return!!i.pushTitle;case 23:return!!i.popTitle;case 24:return!!i.setWinLines}return!1}var wn=0,br=class extends g{constructor(e,t,r,s,o,a,l,h,d=new mr){super();this._bufferService=e;this._charsetService=t;this._coreService=r;this._logService=s;this._optionsService=o;this._oscLinkService=a;this._mouseStateService=l;this._unicodeService=h;this._parser=d;this._parseBuffer=new Uint32Array(4096);this._stringDecoder=new mi;this._utf8Decoder=new bi;this._windowTitle="";this._iconName="";this._windowTitleStack=[];this._iconNameStack=[];this._curAttrData=U.clone();this._eraseAttrDataInternal=U.clone();this._onRequestBell=this._register(new b);this.onRequestBell=this._onRequestBell.event;this._onRequestRefreshRows=this._register(new b);this.onRequestRefreshRows=this._onRequestRefreshRows.event;this._onRequestReset=this._register(new b);this.onRequestReset=this._onRequestReset.event;this._onRequestSendFocus=this._register(new b);this.onRequestSendFocus=this._onRequestSendFocus.event;this._onRequestSyncScrollBar=this._register(new b);this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event;this._onRequestWindowsOptionsReport=this._register(new b);this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event;this._onA11yChar=this._register(new b);this.onA11yChar=this._onA11yChar.event;this._onA11yTab=this._register(new b);this.onA11yTab=this._onA11yTab.event;this._onCursorMove=this._register(new b);this.onCursorMove=this._onCursorMove.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onScroll=this._register(new b);this.onScroll=this._onScroll.event;this._onTitleChange=this._register(new b);this.onTitleChange=this._onTitleChange.event;this._onColor=this._register(new b);this.onColor=this._onColor.event;this._onRequestColorSchemeQuery=this._register(new b);this.onRequestColorSchemeQuery=this._onRequestColorSchemeQuery.event;this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0};this._specialColors=[256,257,258];this._register(this._parser),this._dirtyRowTracker=new hi(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(c=>this._activeBuffer=c.activeBuffer)),this._parser.setCsiHandlerFallback((c,u)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(c),params:u.toArray()})}),this._parser.setEscHandlerFallback(c=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(c)})}),this._parser.setExecuteHandlerFallback(c=>{this._logService.debug("Unknown EXECUTE code: ",{code:c})}),this._parser.setOscHandlerFallback((c,u,_)=>{this._logService.debug("Unknown OSC code: ",{identifier:c,action:u,data:_})}),this._parser.setDcsHandlerFallback((c,u,_)=>{u==="HOOK"&&(_=_.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(c),action:u,payload:_})}),this._parser.setApcHandlerFallback((c,u,_)=>{this._logService.debug("Unknown APC code: ",{identifier:this._parser.identToString(c),action:u,payload:_})}),this._parser.setPrintHandler((c,u,_)=>this.print(c,u,_)),this._parser.registerCsiHandler({final:"@"},c=>this.insertChars(c)),this._parser.registerCsiHandler({intermediates:" ",final:"@"},c=>this.scrollLeft(c)),this._parser.registerCsiHandler({final:"A"},c=>this.cursorUp(c)),this._parser.registerCsiHandler({intermediates:" ",final:"A"},c=>this.scrollRight(c)),this._parser.registerCsiHandler({final:"B"},c=>this.cursorDown(c)),this._parser.registerCsiHandler({final:"C"},c=>this.cursorForward(c)),this._parser.registerCsiHandler({final:"D"},c=>this.cursorBackward(c)),this._parser.registerCsiHandler({final:"E"},c=>this.cursorNextLine(c)),this._parser.registerCsiHandler({final:"F"},c=>this.cursorPrecedingLine(c)),this._parser.registerCsiHandler({final:"G"},c=>this.cursorCharAbsolute(c)),this._parser.registerCsiHandler({final:"H"},c=>this.cursorPosition(c)),this._parser.registerCsiHandler({final:"I"},c=>this.cursorForwardTab(c)),this._parser.registerCsiHandler({final:"J"},c=>this.eraseInDisplay(c,!1)),this._parser.registerCsiHandler({prefix:"?",final:"J"},c=>this.eraseInDisplay(c,!0)),this._parser.registerCsiHandler({final:"K"},c=>this.eraseInLine(c,!1)),this._parser.registerCsiHandler({prefix:"?",final:"K"},c=>this.eraseInLine(c,!0)),this._parser.registerCsiHandler({final:"L"},c=>this.insertLines(c)),this._parser.registerCsiHandler({final:"M"},c=>this.deleteLines(c)),this._parser.registerCsiHandler({final:"P"},c=>this.deleteChars(c)),this._parser.registerCsiHandler({final:"S"},c=>this.scrollUp(c)),this._parser.registerCsiHandler({final:"T"},c=>this.scrollDown(c)),this._parser.registerCsiHandler({final:"X"},c=>this.eraseChars(c)),this._parser.registerCsiHandler({final:"Z"},c=>this.cursorBackwardTab(c)),this._parser.registerCsiHandler({final:"^"},c=>this.scrollDown(c)),this._parser.registerCsiHandler({final:"`"},c=>this.charPosAbsolute(c)),this._parser.registerCsiHandler({final:"a"},c=>this.hPositionRelative(c)),this._parser.registerCsiHandler({final:"b"},c=>this.repeatPrecedingCharacter(c)),this._parser.registerCsiHandler({final:"c"},c=>this.sendDeviceAttributesPrimary(c)),this._parser.registerCsiHandler({prefix:">",final:"c"},c=>this.sendDeviceAttributesSecondary(c)),this._parser.registerCsiHandler({final:"d"},c=>this.linePosAbsolute(c)),this._parser.registerCsiHandler({final:"e"},c=>this.vPositionRelative(c)),this._parser.registerCsiHandler({final:"f"},c=>this.hVPosition(c)),this._parser.registerCsiHandler({final:"g"},c=>this.tabClear(c)),this._parser.registerCsiHandler({final:"h"},c=>this.setMode(c)),this._parser.registerCsiHandler({prefix:"?",final:"h"},c=>this.setModePrivate(c)),this._parser.registerCsiHandler({final:"l"},c=>this.resetMode(c)),this._parser.registerCsiHandler({prefix:"?",final:"l"},c=>this.resetModePrivate(c)),this._parser.registerCsiHandler({final:"m"},c=>this.charAttributes(c)),this._parser.registerCsiHandler({final:"n"},c=>this.deviceStatus(c)),this._parser.registerCsiHandler({prefix:"?",final:"n"},c=>this.deviceStatusPrivate(c)),this._parser.registerCsiHandler({intermediates:"!",final:"p"},c=>this.softReset(c)),this._parser.registerCsiHandler({prefix:">",final:"q"},c=>this.sendXtVersion(c)),this._parser.registerCsiHandler({intermediates:" ",final:"q"},c=>this.setCursorStyle(c)),this._parser.registerCsiHandler({final:"r"},c=>this.setScrollRegion(c)),this._parser.registerCsiHandler({final:"s"},c=>this.saveCursor(c)),this._parser.registerCsiHandler({final:"t"},c=>this.windowOptions(c)),this._parser.registerCsiHandler({final:"u"},c=>this.restoreCursor(c)),this._parser.registerCsiHandler({intermediates:"'",final:"}"},c=>this.insertColumns(c)),this._parser.registerCsiHandler({intermediates:"'",final:"~"},c=>this.deleteColumns(c)),this._parser.registerCsiHandler({intermediates:'"',final:"q"},c=>this.selectProtected(c)),this._parser.registerCsiHandler({intermediates:"$",final:"p"},c=>this.requestMode(c,!0)),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},c=>this.requestMode(c,!1)),this._parser.registerCsiHandler({prefix:"=",final:"u"},c=>this.kittyKeyboardSet(c)),this._parser.registerCsiHandler({prefix:"?",final:"u"},c=>this.kittyKeyboardQuery(c)),this._parser.registerCsiHandler({prefix:">",final:"u"},c=>this.kittyKeyboardPush(c)),this._parser.registerCsiHandler({prefix:"<",final:"u"},c=>this.kittyKeyboardPop(c)),this._parser.setExecuteHandler("\x07",()=>this.bell()),this._parser.setExecuteHandler(` - `,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new ne(c=>(this.setTitle(c),this.setIconName(c),!0))),this._parser.registerOscHandler(1,new ne(c=>this.setIconName(c))),this._parser.registerOscHandler(2,new ne(c=>this.setTitle(c))),this._parser.registerOscHandler(4,new ne(c=>this.setOrReportIndexedColor(c))),this._parser.registerOscHandler(8,new ne(c=>this.setHyperlink(c))),this._parser.registerOscHandler(10,new ne(c=>this.setOrReportFgColor(c))),this._parser.registerOscHandler(11,new ne(c=>this.setOrReportBgColor(c))),this._parser.registerOscHandler(12,new ne(c=>this.setOrReportCursorColor(c))),this._parser.registerOscHandler(104,new ne(c=>this.restoreIndexedColor(c))),this._parser.registerOscHandler(110,new ne(c=>this.restoreFgColor(c))),this._parser.registerOscHandler(111,new ne(c=>this.restoreBgColor(c))),this._parser.registerOscHandler(112,new ne(c=>this.restoreCursorColor(c))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let c in q)this._parser.registerEscHandler({intermediates:"(",final:c},()=>this.selectCharset("("+c)),this._parser.registerEscHandler({intermediates:")",final:c},()=>this.selectCharset(")"+c)),this._parser.registerEscHandler({intermediates:"*",final:c},()=>this.selectCharset("*"+c)),this._parser.registerEscHandler({intermediates:"+",final:c},()=>this.selectCharset("+"+c)),this._parser.registerEscHandler({intermediates:"-",final:c},()=>this.selectCharset("-"+c)),this._parser.registerEscHandler({intermediates:".",final:c},()=>this.selectCharset("."+c)),this._parser.registerEscHandler({intermediates:"/",final:c},()=>this.selectCharset("/"+c));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(c=>(this._logService.error("Parsing error: ",c),c)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new li((c,u)=>this.requestStatusString(c,u)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,r,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=r,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,r=new Promise((s,o)=>{t=setTimeout(()=>o("#SLOW_TIMEOUT"),5e3)});Promise.race([e,r]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let r,s=this._activeBuffer.x,o=this._activeBuffer.y,a=0,l=this._parseStack.paused;if(l){if(r=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(r),r;s=this._parseStack.cursorStartX,o=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(a=this._parseStack.position+131072)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,c=>String.fromCharCode(c)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(c=>c.charCodeAt(0)):e),this._parseBuffer.length131072)for(let c=a;c0&&_.getWidth(this._activeBuffer.x-1)===2&&_.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let p=this._parser.precedingJoinState;for(let v=t;vh){if(d){let L=_,T=this._activeBuffer.x-I;if(this._activeBuffer.x=I,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),_=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!_)return;for(I>0&&_ instanceof De&&_.copyCellsFrom(L,T,0,I,!1);T=0;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(c&&(_.insertCells(this._activeBuffer.x,o-I,this._activeBuffer.getNullCell(u)),_.getWidth(h-1)===2&&_.setCellFromCodepoint(h-1,0,1,u)),_.setCellFromCodepoint(this._activeBuffer.x++,s,o,u),o>0)for(;--o;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=p,this._activeBuffer.x0&&_.getWidth(this._activeBuffer.x)===0&&!_.hasContent(this._activeBuffer.x)&&_.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,r=>xn(r.params[0],this._optionsService.rawOptions.windowOptions)?t(r):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new li(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new ne(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new _r(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,r,s=!1,o=!1){let a=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);a&&(a.replaceCells(t,r,this._activeBuffer.getNullCell(this._eraseAttrData()),o),s&&(a.isWrapped=!1))}_resetBufferLine(e,t=!1){let r=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);r&&(r.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),r.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let r;switch(e.params[0]){case 0:for(r=this._activeBuffer.y,this._dirtyRowTracker.markDirty(r),this._eraseInBufferLine(r++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);r=this._bufferService.cols){let o=this._activeBuffer.lines.get(r+1);o&&(o.isWrapped=!1)}for(;r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(r=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,r-1);r--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+r)?.getTrimmedLength(););for(;r>=0;r--)this._bufferService.scroll(this._eraseAttrData())}else{for(r=this._bufferService.rows,this._dirtyRowTracker.markDirty(r-1);r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let c=d;for(let u=1;u0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${yn})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(te[te.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",te[te.SET=1]="SET",te[te.RESET=2]="RESET",te[te.PERMANENTLY_SET=3]="PERMANENTLY_SET",te[te.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(r||={});let s=this._coreService.decPrivateModes,{activeProtocol:o,activeEncoding:a}=this._mouseStateService,l=this._coreService,{buffers:h,cols:d}=this._bufferService,{active:c,alt:u}=h,_=this._optionsService.rawOptions,p=(S,I)=>(l.triggerDataEvent(`\x1B[${t?"":"?"}${S};${I}$y`),!0),v=S=>S?1:2,f=e.params[0];return t?f===2?p(f,4):f===4?p(f,v(l.modes.insertMode)):f===12?p(f,3):f===20?p(f,v(_.convertEol)):p(f,0):f===1?p(f,v(s.applicationCursorKeys)):f===3?p(f,_.windowOptions.setWinLines?d===80?2:d===132?1:0:0):f===6?p(f,v(s.origin)):f===7?p(f,v(s.wraparound)):f===8?p(f,3):f===9?p(f,v(o==="X10")):f===12?p(f,v(_.cursorBlink)):f===25?p(f,v(!l.isCursorHidden)):f===45?p(f,v(s.reverseWraparound)):f===66?p(f,v(s.applicationKeypad)):f===67?p(f,4):f===1e3?p(f,v(o==="VT200")):f===1002?p(f,v(o==="DRAG")):f===1003?p(f,v(o==="ANY")):f===1004?p(f,v(s.sendFocus)):f===1005?p(f,4):f===1006?p(f,v(a==="SGR")):f===1015?p(f,4):f===1016?p(f,v(a==="SGR_PIXELS")):f===1048?p(f,1):f===47||f===1047||f===1049?p(f,v(c===u)):f===2004?p(f,v(s.bracketedPasteMode)):f===2026?p(f,v(s.synchronizedOutput)):f===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?p(f,v(s.win32InputMode)):p(f,0)}_updateAttrColor(e,t,r,s,o){return t===2?(e|=50331648,e&=-16777216,e|=ue.fromColorRGB([r,s,o])):t===5&&(e&=-67108864,e|=33554432|r&255),e}_extractColor(e,t,r){let s=[0,0,-1,0,0,0],o=0,a=0;do{if(s[a+o]=e.params[t+a],e.hasSubParams(t+a)){let l=e.getSubParams(t+a),h=0;do s[1]===5&&(o=1),s[a+h+1+o]=l[h];while(++h=2||s[1]===2&&a+o>=5)break;s[1]&&(o=1)}while(++a+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=U.fg,e.bg=U.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,r,s=this._curAttrData;for(let o=0;o=30&&r<=37?(s.fg&=-67108864,s.fg|=16777216|r-30):r>=40&&r<=47?(s.bg&=-67108864,s.bg|=16777216|r-40):r>=90&&r<=97?(s.fg&=-67108864,s.fg|=16777216|r-90|8):r>=100&&r<=107?(s.bg&=-67108864,s.bg|=16777216|r-100|8):r===0?this._processSGR0(s):r===1?s.fg|=134217728:r===3?s.bg|=67108864:r===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(o)?e.getSubParams(o)[0]:1,s)):r===5?s.fg|=536870912:r===7?s.fg|=67108864:r===8?s.fg|=1073741824:r===9?s.fg|=2147483648:r===2?s.bg|=134217728:r===21?this._processUnderline(2,s):r===22?(s.fg&=-134217729,s.bg&=-134217729):r===23?s.bg&=-67108865:r===24?(s.fg&=-268435457,this._processUnderline(0,s)):r===25?s.fg&=-536870913:r===27?s.fg&=-67108865:r===28?s.fg&=-1073741825:r===29?s.fg&=2147483647:r===39?(s.fg&=-67108864,s.fg|=U.fg&16777215):r===49?(s.bg&=-67108864,s.bg|=U.bg&16777215):r===38||r===48||r===58?o+=this._extractColor(e,o,s):r===53?s.bg|=1073741824:r===55?s.bg&=-1073741825:r===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:r===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:r===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",r);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${r}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${r}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=U.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let r=t%2===1;this._coreService.decPrivateModes.cursorBlink=r}return!0}setScrollRegion(e){let t=e.params[0]||1,r;return(e.length<2||(r=e.params[1])>this._bufferService.rows||r===0)&&(r=this._bufferService.rows),r>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=r-1,this._setCursor(0,0)),!0}windowOptions(e){if(!xn(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t1;){let s=r.shift(),o=r.shift();if(/^\d+$/.exec(s)){let a=parseInt(s,10);if(Tn(a))if(o==="?")t.push({type:0,index:a});else{let l=ys(o);l&&t.push({type:1,index:a,color:l})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let r=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(r,s):r.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let r=e.split(":"),s,o=r.findIndex(a=>a.startsWith("id="));return o!==-1&&(s=r[o].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let r=e.split(";");for(let s=0;s=this._specialColors.length);++s,++t)if(r[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let o=ys(r[s]);o&&this._onColor.fire([{type:1,index:this._specialColors[t],color:o}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],r=e.split(";");for(let s=0;s=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=U.clone(),this._eraseAttrDataInternal=U.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new F;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`\x1B${l}\x1B\\`),!0),s=this._bufferService.buffer,o=this._optionsService.rawOptions,a={block:2,underline:4,bar:6};return r(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${a[o.cursorStyle]-(o.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(r){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;return o.length>=16&&o.shift(),o.push(r.flags),r.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;for(let a=0;a0;a++)r.flags=o.pop();return o.length===0&&t>0&&(r.flags=0),!0}},hi=class{constructor(i){this._bufferService=i;this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(i){ithis.end&&(this.end=i)}markRangeDirty(i,e){i>e&&(wn=i,i=e,e=wn),ithis.end&&(this.end=e)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};hi=y([m(0,D)],hi);function Tn(n){return 0<=n&&n<256}var vr=class extends g{constructor(e){super();this._action=e;this._writeBuffer=[];this._callbacks=[];this._pendingData=0;this._bufferOffset=0;this._isSyncWriting=!1;this._syncCalls=0;this._didUserInput=!1;this._innerWriteTimer=this._register(new Ie);this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._register(E(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed||this._isSyncWriting)return;this._isSyncWriting=!0;let e,t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);let r=this._callbacks.shift();r&&r()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let r;for(;r=this._writeBuffer.shift();){this._action(r);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;let r=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],o=this._action(s,t);if(o){let l=h=>{this._store.isDisposed||(performance.now()-r>=12?this._scheduleInnerWrite(0,h):this._innerWrite(r,h))};o.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(l);return}let a=this._callbacks[this._bufferOffset];if(a&&a(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-r>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}};var kt=class{constructor(i){this._bufferService=i;this._nextId=1;this._entriesWithId=new Map;this._dataByLinkId=new Map}registerLink(i){let e=this._bufferService.buffer;if(i.id===void 0){let l=e.addMarker(e.ybase+e.y),h={data:i,id:this._nextId++,lines:[l]};return l.onDispose(()=>this._removeMarkerFromLink(h,l)),this._dataByLinkId.set(h.id,h),h.id}let t=i,r=this._getEntryIdKey(t),s=this._entriesWithId.get(r);if(s)return this.addLineToLink(s.id,e.ybase+e.y),s.id;let o=e.addMarker(e.ybase+e.y),a={id:this._nextId++,key:this._getEntryIdKey(t),data:t,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(a,o)),this._entriesWithId.set(a.key,a),this._dataByLinkId.set(a.id,a),a.id}addLineToLink(i,e){let t=this._dataByLinkId.get(i);if(t&&t.lines.every(r=>r.line!==e)){let r=this._bufferService.buffer.addMarker(e);t.lines.push(r),r.onDispose(()=>this._removeMarkerFromLink(t,r))}}getLinkData(i){return this._dataByLinkId.get(i)?.data}_getEntryIdKey(i){return`${i.id};;${i.uri}`}_removeMarkerFromLink(i,e){let t=i.lines.indexOf(e);t!==-1&&(i.lines.splice(t,1),i.lines.length===0&&(i.data.id!==void 0&&this._entriesWithId.delete(i.key),this._dataByLinkId.delete(i.id)))}};kt=y([m(0,D)],kt);var Dn=!1,Sr=class extends g{constructor(e){super();this._windowsWrappingHeuristics=this._register(new P);this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onData=this._register(new b);this.onData=this._onData.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._onScroll=this._register(new b);this._instantiationService=new Ji,this.optionsService=this._register(new nr(e)),this._instantiationService.setService(R,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(wt)),this._instantiationService.setService(fe,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(Dt)),this._instantiationService.setService(D,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(Lt)),this._instantiationService.setService(Y,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(or)),this._instantiationService.setService(Me,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(me)),this.unicodeService.register(new ar),this._instantiationService.setService(Us,this.unicodeService),this._charsetService=this._instantiationService.createInstance(lr),this._instantiationService.setService(Ws,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(kt),this._instantiationService.setService(vi,this._oscLinkService),this._inputHandler=this._register(new br(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(j.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(j.forward(this._bufferService.onResize,this._onResize)),this._register(j.forward(this.coreService.onData,this._onData)),this._register(j.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new vr((t,r)=>this._inputHandler.parse(t,r))),this._register(j.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new b),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Dn&&(this._logService.warn("writeSync is unreliable and will be removed soon."),Dn=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.backend!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Is.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(Is(this._bufferService),!1))),this._windowsWrappingHeuristics.value=E(()=>{for(let t of e)t.dispose()})}}};var z=0,gr=class{constructor(i,e){this._getKey=i;this._array=[];this._insertedValues=[];this._isFlushingInserted=!1;this._deletedIndices=[];this._isFlushingDeleted=!1;this._flushInsertedTask=new It(e),this._flushDeletedTask=new It(e)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(i){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(i)}_flushInserted(){let i=this._insertedValues.sort((s,o)=>this._getKey(s)-this._getKey(o)),e=0,t=0,r=new Array(this._array.length+this._insertedValues.length);for(let s=0;s=this._array.length||this._getKey(i[e])<=this._getKey(this._array[t])?(r[s]=i[e],e++):r[s]=this._array[t++];this._array=r,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(i){if(this._flushCleanupInserted(),this._array.length===0)return!1;let e=this._getKey(i);if(e===void 0||(z=this._search(e),z===-1)||this._getKey(this._array[z])!==e)return!1;do if(this._array[z]===i)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(z),!0;while(++zs-o),e=0,t=new Array(this._array.length-i.length),r=0;for(let s=0;s0&&this._flushDeletedTask.flush()}*getKeyIterator(i){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(z=this._search(i),!(z<0||z>=this._array.length)&&this._getKey(this._array[z])===i))do yield this._array[z];while(++z=this._array.length)&&this._getKey(this._array[z])===i))do e(this._array[z]);while(++z=e;){let r=e+t>>1,s=this._getKey(this._array[r]);if(s>i)t=r-1;else if(s0&&this._getKey(this._array[r-1])===i;)r--;return r}}return e}};var Mt=0,Ir=0,Bt=class extends g{constructor(e,t){super();this._logService=e;this._bufferService=t;this._lineCache=this._register(new xs);this._onDecorationRegistered=this._register(new b);this.onDecorationRegistered=this._onDecorationRegistered.event;this._onDecorationRemoved=this._register(new b);this.onDecorationRemoved=this._onDecorationRemoved.event;this._decorations=new gr(r=>r?.marker.line,this._logService),this._register(E(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new ws(e);if(t){let r=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),r.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,r){let s=this._lineCache.getDecorationsOnLine(t);if(s)for(let o of s)Mt=o.options.x??0,Ir=Mt+(o.options.width??1),e>=Mt&&e=Mt&&ethis._handleBufferLinesTrim(r))),t.add(e.onInsert(r=>this._handleBufferLinesInsert(r))),t.add(e.onDelete(r=>this._handleBufferLinesDelete(r)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){let t=e.marker.line;if(t<0)return;e._indexedStartLine=t;let r=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{let t=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(let r of t)r()})}_handleBufferLinesTrim(e){if(e<=0)return;let t=new Map;for(let[r,s]of this._decorationsByLine){let o=r-e;o<0||this._mergeLineBucket(t,o,s)}this._decorationsByLine.clear();for(let[r,s]of t)this._decorationsByLine.set(r,s);for(let r of this._decorations)r.marker.isDisposed||(r._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,r){let s=e.get(t);if(s)for(let o=0,a=r.length;ot&&(s.push(a),this._removeFromLineBuckets(a))}let o=new Map;for(let[a,l]of this._decorationsByLine){let h=a>=t?a+r:a;this._mergeLineBucket(o,h,l)}this._decorationsByLine.clear();for(let[a,l]of o)this._decorationsByLine.set(a,l);for(let a of this._decorations)a.marker.isDisposed||a._indexedStartLine>=t&&(a._indexedStartLine=a.marker.line);for(let a of s)this._addToLineBuckets(a)}_applyBufferLinesDelete(e){let t=e.index+e.amount,r=new Map;for(let[o,a]of this._decorationsByLine){if(o>=e.index&&o=t?o-e.amount:o;this._mergeLineBucket(r,l,a)}this._decorationsByLine.clear();for(let[o,a]of r)this._decorationsByLine.set(o,a);let s=[];for(let o of this._decorations){if(o.marker.isDisposed)continue;let a=o._indexedStartLine,l=this._getDecorationHeight(o);a>=t?o._indexedStartLine=o.marker.line:at&&s.push(o)}for(let o of s)this._reindexDecoration(o)}},ws=class extends pe{constructor(e){super();this.options=e;this.onRenderEmitter=this.add(new b);this.onRender=this.onRenderEmitter.event;this._onDispose=this.add(new b);this.onDispose=this._onDispose.event;this._cachedBg=null;this._cachedFg=null;this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}get backgroundColorRGB(){return this._cachedBg===null&&(this.options.backgroundColor?this._cachedBg=B.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return this._cachedFg===null&&(this.options.foregroundColor?this._cachedFg=B.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}dispose(){this._onDispose.fire(),super.dispose()}};var yo=1e3,Cr=class{constructor(i,e=yo){this._renderCallback=i;this._debounceThresholdMS=e;this._lastRefreshMs=0;this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e;let r=performance.now();if(r-this._lastRefreshMs>=this._debounceThresholdMS)this._refreshTimeoutID!==void 0&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=r,this._innerRefresh();else if(!this._additionalRefreshRequested){let s=r-this._lastRefreshMs,o=this._debounceThresholdMS-s;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},o)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e)}};var Rn=!1,Ye=class extends g{constructor(e,t,r,s){super();this._terminal=e;this._coreBrowserService=r;this._renderService=s;this._rowColumns=new WeakMap;this._liveRegionLineCount=0;this._charsToConsume=[];this._charsToAnnounce="";let o=this._coreBrowserService.mainDocument;this._accessibilityContainer=o.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=o.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let a=0;athis._handleBoundaryFocus(a,0),this._bottomBoundaryFocusListener=a=>this._handleBoundaryFocus(a,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=o.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Cr(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");Rn?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=o.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(o.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(o.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(a=>this._handleResize(a.rows))),this._register(this._terminal.onRender(a=>this._refreshRows(a.start,a.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(a=>this._handleChar(a))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` +-`,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new ne(c=>(this.setTitle(c),this.setIconName(c),!0))),this._parser.registerOscHandler(1,new ne(c=>this.setIconName(c))),this._parser.registerOscHandler(2,new ne(c=>this.setTitle(c))),this._parser.registerOscHandler(4,new ne(c=>this.setOrReportIndexedColor(c))),this._parser.registerOscHandler(8,new ne(c=>this.setHyperlink(c))),this._parser.registerOscHandler(10,new ne(c=>this.setOrReportFgColor(c))),this._parser.registerOscHandler(11,new ne(c=>this.setOrReportBgColor(c))),this._parser.registerOscHandler(12,new ne(c=>this.setOrReportCursorColor(c))),this._parser.registerOscHandler(104,new ne(c=>this.restoreIndexedColor(c))),this._parser.registerOscHandler(110,new ne(c=>this.restoreFgColor(c))),this._parser.registerOscHandler(111,new ne(c=>this.restoreBgColor(c))),this._parser.registerOscHandler(112,new ne(c=>this.restoreCursorColor(c))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let c in q)this._parser.registerEscHandler({intermediates:"(",final:c},()=>this.selectCharset("("+c)),this._parser.registerEscHandler({intermediates:")",final:c},()=>this.selectCharset(")"+c)),this._parser.registerEscHandler({intermediates:"*",final:c},()=>this.selectCharset("*"+c)),this._parser.registerEscHandler({intermediates:"+",final:c},()=>this.selectCharset("+"+c)),this._parser.registerEscHandler({intermediates:"-",final:c},()=>this.selectCharset("-"+c)),this._parser.registerEscHandler({intermediates:".",final:c},()=>this.selectCharset("."+c)),this._parser.registerEscHandler({intermediates:"/",final:c},()=>this.selectCharset("/"+c));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(c=>(this._logService.error("Parsing error: ",c),c)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new li((c,u)=>this.requestStatusString(c,u)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,r,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=r,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,r=new Promise((s,o)=>{t=setTimeout(()=>o("#SLOW_TIMEOUT"),5e3)});Promise.race([e,r]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let r,s=this._activeBuffer.x,o=this._activeBuffer.y,a=0,l=this._parseStack.paused;if(l){if(r=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(r),r;s=this._parseStack.cursorStartX,o=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(a=this._parseStack.position+131072)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,c=>String.fromCharCode(c)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(c=>c.charCodeAt(0)):e),this._parseBuffer.length131072)for(let c=a;c0&&_.getWidth(this._activeBuffer.x-1)===2&&_.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let p=this._parser.precedingJoinState;for(let v=t;vh){if(d){let L=_,T=this._activeBuffer.x-I;if(this._activeBuffer.x=I,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),_=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!_)return;for(I>0&&_ instanceof De&&_.copyCellsFrom(L,T,0,I,!1);T=0;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(c&&(_.insertCells(this._activeBuffer.x,o-I,this._activeBuffer.getNullCell(u)),_.getWidth(h-1)===2&&_.setCellFromCodepoint(h-1,0,1,u)),_.setCellFromCodepoint(this._activeBuffer.x++,s,o,u),o>0)for(;--o;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=p,this._activeBuffer.x0&&_.getWidth(this._activeBuffer.x)===0&&!_.hasContent(this._activeBuffer.x)&&_.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,r=>xn(r.params[0],this._optionsService.rawOptions.windowOptions)?t(r):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new li(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new ne(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new _r(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,r,s=!1,o=!1){let a=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);a&&(a.replaceCells(t,r,this._activeBuffer.getNullCell(this._eraseAttrData()),o),s&&(a.isWrapped=!1))}_resetBufferLine(e,t=!1){let r=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);r&&(r.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),r.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let r;switch(e.params[0]){case 0:for(r=this._activeBuffer.y,this._dirtyRowTracker.markDirty(r),this._eraseInBufferLine(r++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);r=this._bufferService.cols){let o=this._activeBuffer.lines.get(r+1);o&&(o.isWrapped=!1)}for(;r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(r=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,r-1);r--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+r)?.getTrimmedLength(););for(;r>=0;r--)this._bufferService.scroll(this._eraseAttrData())}else{for(r=this._bufferService.rows,this._dirtyRowTracker.markDirty(r-1);r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let c=d;for(let u=1;u0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${yn})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(te[te.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",te[te.SET=1]="SET",te[te.RESET=2]="RESET",te[te.PERMANENTLY_SET=3]="PERMANENTLY_SET",te[te.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(r||={});let s=this._coreService.decPrivateModes,{activeProtocol:o,activeEncoding:a}=this._mouseStateService,l=this._coreService,{buffers:h,cols:d}=this._bufferService,{active:c,alt:u}=h,_=this._optionsService.rawOptions,p=(S,I)=>(l.triggerDataEvent(`\x1B[${t?"":"?"}${S};${I}$y`),!0),v=S=>S?1:2,f=e.params[0];return t?f===2?p(f,4):f===4?p(f,v(l.modes.insertMode)):f===12?p(f,3):f===20?p(f,v(_.convertEol)):p(f,0):f===1?p(f,v(s.applicationCursorKeys)):f===3?p(f,_.windowOptions.setWinLines?d===80?2:d===132?1:0:0):f===6?p(f,v(s.origin)):f===7?p(f,v(s.wraparound)):f===8?p(f,3):f===9?p(f,v(o==="X10")):f===12?p(f,v(_.cursorBlink)):f===25?p(f,v(!l.isCursorHidden)):f===45?p(f,v(s.reverseWraparound)):f===66?p(f,v(s.applicationKeypad)):f===67?p(f,4):f===1e3?p(f,v(o==="VT200")):f===1002?p(f,v(o==="DRAG")):f===1003?p(f,v(o==="ANY")):f===1004?p(f,v(s.sendFocus)):f===1005?p(f,4):f===1006?p(f,v(a==="SGR")):f===1015?p(f,4):f===1016?p(f,v(a==="SGR_PIXELS")):f===1048?p(f,1):f===47||f===1047||f===1049?p(f,v(c===u)):f===2004?p(f,v(s.bracketedPasteMode)):f===2026?p(f,v(s.synchronizedOutput)):f===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?p(f,v(s.win32InputMode)):p(f,0)}_updateAttrColor(e,t,r,s,o){return t===2?(e|=50331648,e&=-16777216,e|=ue.fromColorRGB([r,s,o])):t===5&&(e&=-67108864,e|=33554432|r&255),e}_extractColor(e,t,r){let s=[0,0,-1,0,0,0],o=0,a=0;do{if(s[a+o]=e.params[t+a],e.hasSubParams(t+a)){let l=e.getSubParams(t+a),h=0;do s[1]===5&&(o=1),s[a+h+1+o]=l[h];while(++h=2||s[1]===2&&a+o>=5)break;s[1]&&(o=1)}while(++a+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=U.fg,e.bg=U.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,r,s=this._curAttrData;for(let o=0;o=30&&r<=37?(s.fg&=-67108864,s.fg|=16777216|r-30):r>=40&&r<=47?(s.bg&=-67108864,s.bg|=16777216|r-40):r>=90&&r<=97?(s.fg&=-67108864,s.fg|=16777216|r-90|8):r>=100&&r<=107?(s.bg&=-67108864,s.bg|=16777216|r-100|8):r===0?this._processSGR0(s):r===1?s.fg|=134217728:r===3?s.bg|=67108864:r===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(o)?e.getSubParams(o)[0]:1,s)):r===5?s.fg|=536870912:r===7?s.fg|=67108864:r===8?s.fg|=1073741824:r===9?s.fg|=2147483648:r===2?s.bg|=134217728:r===21?this._processUnderline(2,s):r===22?(s.fg&=-134217729,s.bg&=-134217729):r===23?s.bg&=-67108865:r===24?(s.fg&=-268435457,this._processUnderline(0,s)):r===25?s.fg&=-536870913:r===27?s.fg&=-67108865:r===28?s.fg&=-1073741825:r===29?s.fg&=2147483647:r===39?(s.fg&=-67108864,s.fg|=U.fg&16777215):r===49?(s.bg&=-67108864,s.bg|=U.bg&16777215):r===38||r===48||r===58?o+=this._extractColor(e,o,s):r===53?s.bg|=1073741824:r===55?s.bg&=-1073741825:r===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:r===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:r===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",r);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${r}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${r}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=U.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let r=t%2===1;this._coreService.decPrivateModes.cursorBlink=r}return!0}setScrollRegion(e){let t=e.params[0]||1,r;return(e.length<2||(r=e.params[1])>this._bufferService.rows||r===0)&&(r=this._bufferService.rows),r>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=r-1,this._setCursor(0,0)),!0}windowOptions(e){if(!xn(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t1;){let s=r.shift(),o=r.shift();if(/^\d+$/.exec(s)){let a=parseInt(s,10);if(Tn(a))if(o==="?")t.push({type:0,index:a});else{let l=ys(o);l&&t.push({type:1,index:a,color:l})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let r=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(r,s):r.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let r=e.split(":"),s,o=r.findIndex(a=>a.startsWith("id="));return o!==-1&&(s=r[o].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let r=e.split(";");for(let s=0;s=this._specialColors.length);++s,++t)if(r[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let o=ys(r[s]);o&&this._onColor.fire([{type:1,index:this._specialColors[t],color:o}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],r=e.split(";");for(let s=0;s=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=U.clone(),this._eraseAttrDataInternal=U.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new F;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`\x1B${l}\x1B\\`),!0),s=this._bufferService.buffer,o=this._optionsService.rawOptions,a={block:2,underline:4,bar:6};return r(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${a[o.cursorStyle]-(o.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(r){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;return o.length>=16&&o.shift(),o.push(r.flags),r.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;for(let a=0;a0;a++)r.flags=o.pop();return o.length===0&&t>0&&(r.flags=0),!0}},hi=class{constructor(i){this._bufferService=i;this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(i){ithis.end&&(this.end=i)}markRangeDirty(i,e){i>e&&(wn=i,i=e,e=wn),ithis.end&&(this.end=e)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};hi=y([m(0,D)],hi);function Tn(n){return 0<=n&&n<256}var vr=class extends g{constructor(e){super();this._action=e;this._writeBuffer=[];this._callbacks=[];this._pendingData=0;this._bufferOffset=0;this._isSyncWriting=!1;this._syncCalls=0;this._didUserInput=!1;this._innerWriteTimer=this._register(new Ie);this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._register(E(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed||this._isSyncWriting)return;this._isSyncWriting=!0;let e,t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);let r=this._callbacks.shift();r&&r()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let r;for(;r=this._writeBuffer.shift();){this._action(r);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;let r=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],o=this._action(s,t);if(o){let l=h=>{this._store.isDisposed||(performance.now()-r>=12?this._scheduleInnerWrite(0,h):this._innerWrite(r,h))};o.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(l);return}let a=this._callbacks[this._bufferOffset];if(a&&a(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-r>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}};var kt=class{constructor(i){this._bufferService=i;this._nextId=1;this._entriesWithId=new Map;this._dataByLinkId=new Map}registerLink(i){let e=this._bufferService.buffer;if(i.id===void 0){let l=e.addMarker(e.ybase+e.y),h={data:i,id:this._nextId++,lines:[l]};return l.onDispose(()=>this._removeMarkerFromLink(h,l)),this._dataByLinkId.set(h.id,h),h.id}let t=i,r=this._getEntryIdKey(t),s=this._entriesWithId.get(r);if(s)return this.addLineToLink(s.id,e.ybase+e.y),s.id;let o=e.addMarker(e.ybase+e.y),a={id:this._nextId++,key:this._getEntryIdKey(t),data:t,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(a,o)),this._entriesWithId.set(a.key,a),this._dataByLinkId.set(a.id,a),a.id}addLineToLink(i,e){let t=this._dataByLinkId.get(i);if(t&&t.lines.every(r=>r.line!==e)){let r=this._bufferService.buffer.addMarker(e);t.lines.push(r),r.onDispose(()=>this._removeMarkerFromLink(t,r))}}getLinkData(i){return this._dataByLinkId.get(i)?.data}_getEntryIdKey(i){return`${i.id};;${i.uri}`}_removeMarkerFromLink(i,e){let t=i.lines.indexOf(e);t!==-1&&(i.lines.splice(t,1),i.lines.length===0&&(i.data.id!==void 0&&this._entriesWithId.delete(i.key),this._dataByLinkId.delete(i.id)))}};kt=y([m(0,D)],kt);var Dn=!1,Sr=class extends g{constructor(e){super();this._windowsWrappingHeuristics=this._register(new P);this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onData=this._register(new b);this.onData=this._onData.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._onScroll=this._register(new b);this._instantiationService=new Ji,this.optionsService=this._register(new nr(e)),this._instantiationService.setService(R,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(wt)),this._instantiationService.setService(fe,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(Dt)),this._instantiationService.setService(D,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(Lt)),this._instantiationService.setService(Y,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(or)),this._instantiationService.setService(Me,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(me)),this.unicodeService.register(new ar),this._instantiationService.setService(Us,this.unicodeService),this._charsetService=this._instantiationService.createInstance(lr),this._instantiationService.setService(Ws,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(kt),this._instantiationService.setService(vi,this._oscLinkService),this._inputHandler=this._register(new br(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(j.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(j.forward(this._bufferService.onResize,this._onResize)),this._register(j.forward(this.coreService.onData,this._onData)),this._register(j.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new vr((t,r)=>this._inputHandler.parse(t,r))),this._register(j.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new b),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Dn&&(this._logService.warn("writeSync is unreliable and will be removed soon."),Dn=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.backend!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Is.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(Is(this._bufferService),!1))),this._windowsWrappingHeuristics.value=E(()=>{for(let t of e)t.dispose()})}}};var z=0,gr=class{constructor(i,e){this._getKey=i;this._array=[];this._insertedValues=[];this._isFlushingInserted=!1;this._deletedIndices=[];this._isFlushingDeleted=!1;this._flushInsertedTask=new It(e),this._flushDeletedTask=new It(e)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(i){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(i)}_flushInserted(){let i=this._insertedValues.sort((s,o)=>this._getKey(s)-this._getKey(o)),e=0,t=0,r=new Array(this._array.length+this._insertedValues.length);for(let s=0;s=this._array.length||this._getKey(i[e])<=this._getKey(this._array[t])?(r[s]=i[e],e++):r[s]=this._array[t++];this._array=r,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(i){if(this._flushCleanupInserted(),this._array.length===0)return!1;let e=this._getKey(i);if(e===void 0||(z=this._search(e),z===-1)||this._getKey(this._array[z])!==e)return!1;do if(this._array[z]===i)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(z),!0;while(++zs-o),e=0,t=new Array(this._array.length-i.length),r=0;for(let s=0;s0&&this._flushDeletedTask.flush()}*getKeyIterator(i){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(z=this._search(i),!(z<0||z>=this._array.length)&&this._getKey(this._array[z])===i))do yield this._array[z];while(++z=this._array.length)&&this._getKey(this._array[z])===i))do e(this._array[z]);while(++z=e;){let r=e+t>>1,s=this._getKey(this._array[r]);if(s>i)t=r-1;else if(s0&&this._getKey(this._array[r-1])===i;)r--;return r}}return e}};var Mt=0,Ir=0,Bt=class extends g{constructor(e,t){super();this._logService=e;this._bufferService=t;this._lineCache=this._register(new xs);this._onDecorationRegistered=this._register(new b);this.onDecorationRegistered=this._onDecorationRegistered.event;this._onDecorationRemoved=this._register(new b);this.onDecorationRemoved=this._onDecorationRemoved.event;this._decorations=new gr(r=>r?.marker.line,this._logService),this._register(E(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new ws(e);if(t){let r=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),r.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,r){let s=this._lineCache.getDecorationsOnLine(t);if(s)for(let o of s)Mt=o.options.x??0,Ir=Mt+(o.options.width??1),e>=Mt&&e=Mt&&ethis._handleBufferLinesTrim(r))),t.add(e.onInsert(r=>this._handleBufferLinesInsert(r))),t.add(e.onDelete(r=>this._handleBufferLinesDelete(r)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){let t=e.marker.line;if(t<0)return;e._indexedStartLine=t;let r=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{let t=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(let r of t)r()})}_handleBufferLinesTrim(e){if(e<=0)return;let t=new Map;for(let[r,s]of this._decorationsByLine){let o=r-e;o<0||this._mergeLineBucket(t,o,s)}this._decorationsByLine.clear();for(let[r,s]of t)this._decorationsByLine.set(r,s);for(let r of this._decorations)r.marker.isDisposed||(r._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,r){let s=e.get(t);if(s)for(let o=0,a=r.length;ot&&(s.push(a),this._removeFromLineBuckets(a))}let o=new Map;for(let[a,l]of this._decorationsByLine){let h=a>=t?a+r:a;this._mergeLineBucket(o,h,l)}this._decorationsByLine.clear();for(let[a,l]of o)this._decorationsByLine.set(a,l);for(let a of this._decorations)a.marker.isDisposed||a._indexedStartLine>=t&&(a._indexedStartLine=a.marker.line);for(let a of s)this._addToLineBuckets(a)}_applyBufferLinesDelete(e){let t=e.index+e.amount,r=new Map;for(let[o,a]of this._decorationsByLine){if(o>=e.index&&o=t?o-e.amount:o;this._mergeLineBucket(r,l,a)}this._decorationsByLine.clear();for(let[o,a]of r)this._decorationsByLine.set(o,a);let s=[];for(let o of this._decorations){if(o.marker.isDisposed)continue;let a=o._indexedStartLine,l=this._getDecorationHeight(o);a>=t?o._indexedStartLine=o.marker.line:at&&s.push(o)}for(let o of s)this._reindexDecoration(o)}},ws=class extends pe{constructor(e){super();this.options=e;this.onRenderEmitter=this.add(new b);this.onRender=this.onRenderEmitter.event;this._onDispose=this.add(new b);this.onDispose=this._onDispose.event;this._cachedBg=null;this._cachedFg=null;this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}get backgroundColorRGB(){return this._cachedBg===null&&(this.options.backgroundColor?this._cachedBg=B.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return this._cachedFg===null&&(this.options.foregroundColor?this._cachedFg=B.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}dispose(){this._onDispose.fire(),super.dispose()}};var yo=1e3,Cr=class{constructor(i,e=yo){this._renderCallback=i;this._debounceThresholdMS=e;this._lastRefreshMs=0;this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e;let r=performance.now();if(r-this._lastRefreshMs>=this._debounceThresholdMS)this._refreshTimeoutID!==void 0&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=r,this._innerRefresh();else if(!this._additionalRefreshRequested){let s=r-this._lastRefreshMs,o=this._debounceThresholdMS-s;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},o)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e)}};var Rn=!1,Ye=class extends g{constructor(e,t,r,s){super();this._terminal=e;this._coreBrowserService=r;this._renderService=s;this._rowColumns=new WeakMap;this._liveRegionLineCount=0;this._charsToConsume=[];this._charsToAnnounce="";let o=this._coreBrowserService.mainDocument;this._accessibilityContainer=o.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=o.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let a=0;athis._handleBoundaryFocus(a,0),this._bottomBoundaryFocusListener=a=>this._handleBoundaryFocus(a,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=o.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Cr(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");Rn?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=o.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(o.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(o.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(a=>this._handleResize(a.rows))),this._register(this._terminal.onRender(a=>this._refreshRows(a.start,a.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(a=>this._handleChar(a))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` ++`,()=>this.lineFeed()),this._parser.setExecuteHandler("\v",()=>this.lineFeed()),this._parser.setExecuteHandler("\f",()=>this.lineFeed()),this._parser.setExecuteHandler("\r",()=>this.carriageReturn()),this._parser.setExecuteHandler("\b",()=>this.backspace()),this._parser.setExecuteHandler(" ",()=>this.tab()),this._parser.setExecuteHandler("",()=>this.shiftOut()),this._parser.setExecuteHandler("",()=>this.shiftIn()),this._parser.setExecuteHandler("\x84",()=>this.index()),this._parser.setExecuteHandler("\x85",()=>this.nextLine()),this._parser.setExecuteHandler("\x88",()=>this.tabSet()),this._parser.registerOscHandler(0,new ne(c=>(this.setTitle(c),this.setIconName(c),!0))),this._parser.registerOscHandler(1,new ne(c=>this.setIconName(c))),this._parser.registerOscHandler(2,new ne(c=>this.setTitle(c))),this._parser.registerOscHandler(4,new ne(c=>this.setOrReportIndexedColor(c))),this._parser.registerOscHandler(8,new ne(c=>this.setHyperlink(c))),this._parser.registerOscHandler(10,new ne(c=>this.setOrReportFgColor(c))),this._parser.registerOscHandler(11,new ne(c=>this.setOrReportBgColor(c))),this._parser.registerOscHandler(12,new ne(c=>this.setOrReportCursorColor(c))),this._parser.registerOscHandler(104,new ne(c=>this.restoreIndexedColor(c))),this._parser.registerOscHandler(110,new ne(c=>this.restoreFgColor(c))),this._parser.registerOscHandler(111,new ne(c=>this.restoreBgColor(c))),this._parser.registerOscHandler(112,new ne(c=>this.restoreCursorColor(c))),this._parser.registerEscHandler({final:"7"},()=>this.saveCursor()),this._parser.registerEscHandler({final:"8"},()=>this.restoreCursor()),this._parser.registerEscHandler({final:"D"},()=>this.index()),this._parser.registerEscHandler({final:"E"},()=>this.nextLine()),this._parser.registerEscHandler({final:"H"},()=>this.tabSet()),this._parser.registerEscHandler({final:"M"},()=>this.reverseIndex()),this._parser.registerEscHandler({final:"="},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:">"},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:"c"},()=>this.fullReset()),this._parser.registerEscHandler({final:"n"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"o"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"|"},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:"}"},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:"~"},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:"%",final:"@"},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:"%",final:"G"},()=>this.selectDefaultCharset());for(let c in q)this._parser.registerEscHandler({intermediates:"(",final:c},()=>this.selectCharset("("+c)),this._parser.registerEscHandler({intermediates:")",final:c},()=>this.selectCharset(")"+c)),this._parser.registerEscHandler({intermediates:"*",final:c},()=>this.selectCharset("*"+c)),this._parser.registerEscHandler({intermediates:"+",final:c},()=>this.selectCharset("+"+c)),this._parser.registerEscHandler({intermediates:"-",final:c},()=>this.selectCharset("-"+c)),this._parser.registerEscHandler({intermediates:".",final:c},()=>this.selectCharset("."+c)),this._parser.registerEscHandler({intermediates:"/",final:c},()=>this.selectCharset("/"+c));this._parser.registerEscHandler({intermediates:"#",final:"8"},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(c=>(this._logService.error("Parsing error: ",c),c)),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new li((c,u)=>this.requestStatusString(c,u)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,r,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=r,this._parseStack.position=s}_logSlowResolvingAsync(e){if(this._logService.logLevel<=3){let t,r=new Promise((s,o)=>{t=setTimeout(()=>o("#SLOW_TIMEOUT"),5e3)});Promise.race([e,r]).then(()=>{t!==void 0&&clearTimeout(t)},s=>{if(t!==void 0&&clearTimeout(t),s!=="#SLOW_TIMEOUT")throw s;console.warn("async parser handler taking longer than 5000 ms")})}}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let r,s=this._activeBuffer.x,o=this._activeBuffer.y,a=0,l=this._parseStack.paused;if(l){if(r=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(r),r;s=this._parseStack.cursorStartX,o=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>131072&&(a=this._parseStack.position+131072)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e=="string"?` "${e}"`:` "${Array.prototype.map.call(e,c=>String.fromCharCode(c)).join("")}"`}`),this._logService.logLevel===0&&this._logService.trace("parsing data (codes)",typeof e=="string"?e.split("").map(c=>c.charCodeAt(0)):e),this._parseBuffer.length131072)for(let c=a;c0&&_.getWidth(this._activeBuffer.x-1)===2&&_.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let p=this._parser.precedingJoinState;for(let v=t;vh){if(d){let L=_,T=this._activeBuffer.x-I;if(this._activeBuffer.x=I,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),_=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),!_)return;for(I>0&&_ instanceof De&&_.copyCellsFrom(L,T,0,I,!1);T=0;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(c&&(_.insertCells(this._activeBuffer.x,o-I,this._activeBuffer.getNullCell(u)),_.getWidth(h-1)===2&&_.setCellFromCodepoint(h-1,0,1,u)),_.setCellFromCodepoint(this._activeBuffer.x++,s,o,u),o>0)for(;--o;)_.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=p,this._activeBuffer.x0&&_.getWidth(this._activeBuffer.x)===0&&!_.hasContent(this._activeBuffer.x)&&_.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final==="t"&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,r=>xn(r.params[0],this._optionsService.rawOptions.windowOptions)?t(r):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new li(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new ne(t))}registerApcHandler(e,t){return this._parser.registerApcHandler(e,new _r(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,r,s=!1,o=!1){let a=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);a&&(a.replaceCells(t,r,this._activeBuffer.getNullCell(this._eraseAttrData()),o),s&&(a.isWrapped=!1))}_resetBufferLine(e,t=!1){let r=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);r&&(r.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),r.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let r;switch(e.params[0]){case 0:for(r=this._activeBuffer.y,this._dirtyRowTracker.markDirty(r),this._eraseInBufferLine(r++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);r=this._bufferService.cols){let o=this._activeBuffer.lines.get(r+1);o&&(o.isWrapped=!1)}for(;r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(r=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,r-1);r--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+r)?.getTrimmedLength(););for(;r>=0;r--)this._bufferService.scroll(this._eraseAttrData())}else{for(r=this._bufferService.rows,this._dirtyRowTracker.markDirty(r-1);r--;)this._resetBufferLine(r,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let s=this._activeBuffer.lines.length-this._bufferService.rows;s>0&&(this._activeBuffer.lines.trimStart(s),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-s,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-s,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let c=d;for(let u=1;u0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent("\x1B[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent("\x1B[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent("\x1B[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent("\x1B[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent("\x1B[>83;40003;0c")),!0}sendXtVersion(e){return e.params[0]>0||this._coreService.triggerDataEvent(`\x1BP>|xterm.js(${yn})\x1B\\`),!0}_is(e){return(this._optionsService.rawOptions.termName+"").startsWith(e)}setMode(e){for(let t=0;t(te[te.NOT_RECOGNIZED=0]="NOT_RECOGNIZED",te[te.SET=1]="SET",te[te.RESET=2]="RESET",te[te.PERMANENTLY_SET=3]="PERMANENTLY_SET",te[te.PERMANENTLY_RESET=4]="PERMANENTLY_RESET"))(r||={});let s=this._coreService.decPrivateModes,{activeProtocol:o,activeEncoding:a}=this._mouseStateService,l=this._coreService,{buffers:h,cols:d}=this._bufferService,{active:c,alt:u}=h,_=this._optionsService.rawOptions,p=(S,I)=>(l.triggerDataEvent(`\x1B[${t?"":"?"}${S};${I}$y`),!0),v=S=>S?1:2,f=e.params[0];return t?f===2?p(f,4):f===4?p(f,v(l.modes.insertMode)):f===12?p(f,3):f===20?p(f,v(_.convertEol)):p(f,0):f===1?p(f,v(s.applicationCursorKeys)):f===3?p(f,_.windowOptions.setWinLines?d===80?2:d===132?1:0:0):f===6?p(f,v(s.origin)):f===7?p(f,v(s.wraparound)):f===8?p(f,3):f===9?p(f,v(o==="X10")):f===12?p(f,v(_.cursorBlink)):f===25?p(f,v(!l.isCursorHidden)):f===45?p(f,v(s.reverseWraparound)):f===66?p(f,v(s.applicationKeypad)):f===67?p(f,4):f===1e3?p(f,v(o==="VT200")):f===1002?p(f,v(o==="DRAG")):f===1003?p(f,v(o==="ANY")):f===1004?p(f,v(s.sendFocus)):f===1005?p(f,4):f===1006?p(f,v(a==="SGR")):f===1015?p(f,4):f===1016?p(f,v(a==="SGR_PIXELS")):f===1048?p(f,1):f===47||f===1047||f===1049?p(f,v(c===u)):f===2004?p(f,v(s.bracketedPasteMode)):f===2026?p(f,v(s.synchronizedOutput)):f===9001&&this._optionsService.rawOptions.vtExtensions?.win32InputMode?p(f,v(s.win32InputMode)):p(f,0)}_updateAttrColor(e,t,r,s,o){return t===2?(e|=50331648,e&=-16777216,e|=ue.fromColorRGB([r,s,o])):t===5&&(e&=-67108864,e|=33554432|r&255),e}_extractColor(e,t,r){let s=[0,0,-1,0,0,0],o=0,a=0;do{if(s[a+o]=e.params[t+a],e.hasSubParams(t+a)){let l=e.getSubParams(t+a),h=0;do s[1]===5&&(o=1),s[a+h+1+o]=l[h];while(++h=2||s[1]===2&&a+o>=5)break;s[1]&&(o=1)}while(++a+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=U.fg,e.bg=U.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,r,s=this._curAttrData;for(let o=0;o=30&&r<=37?(s.fg&=-67108864,s.fg|=16777216|r-30):r>=40&&r<=47?(s.bg&=-67108864,s.bg|=16777216|r-40):r>=90&&r<=97?(s.fg&=-67108864,s.fg|=16777216|r-90|8):r>=100&&r<=107?(s.bg&=-67108864,s.bg|=16777216|r-100|8):r===0?this._processSGR0(s):r===1?s.fg|=134217728:r===3?s.bg|=67108864:r===4?(s.fg|=268435456,this._processUnderline(e.hasSubParams(o)?e.getSubParams(o)[0]:1,s)):r===5?s.fg|=536870912:r===7?s.fg|=67108864:r===8?s.fg|=1073741824:r===9?s.fg|=2147483648:r===2?s.bg|=134217728:r===21?this._processUnderline(2,s):r===22?(s.fg&=-134217729,s.bg&=-134217729):r===23?s.bg&=-67108865:r===24?(s.fg&=-268435457,this._processUnderline(0,s)):r===25?s.fg&=-536870913:r===27?s.fg&=-67108865:r===28?s.fg&=-1073741825:r===29?s.fg&=2147483647:r===39?(s.fg&=-67108864,s.fg|=U.fg&16777215):r===49?(s.bg&=-67108864,s.bg|=U.bg&16777215):r===38||r===48||r===58?o+=this._extractColor(e,o,s):r===53?s.bg|=1073741824:r===55?s.bg&=-1073741825:r===221&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.fg&=-134217729:r===222&&(this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl??!0)?s.bg&=-134217729:r===59?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):this._logService.debug("Unknown SGR attribute: %d.",r);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent("\x1B[0n");break;case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[${t};${r}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let t=this._activeBuffer.y+1,r=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`\x1B[?${t};${r}R`);break;case 15:break;case 25:break;case 26:break;case 53:break;case 996:(this._optionsService.rawOptions.vtExtensions?.colorSchemeQuery??!0)&&this._onRequestColorSchemeQuery.fire();break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=U.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle="block";break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle="underline";break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle="bar";break}let r=t%2===1;this._coreService.decPrivateModes.cursorBlink=r}return!0}setScrollRegion(e){let t=e.params[0]||1,r;return(e.length<2||(r=e.params[1])>this._bufferService.rows||r===0)&&(r=this._bufferService.rows),r>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=r-1,this._setCursor(0,0)),!0}windowOptions(e){if(!xn(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`\x1B[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._activeBuffer.savedCharsets=this._charsetService.charsets.slice(),this._activeBuffer.savedGlevel=this._charsetService.glevel,this._activeBuffer.savedOriginMode=this._coreService.decPrivateModes.origin,this._activeBuffer.savedWraparoundMode=this._coreService.decPrivateModes.wraparound,!0}restoreCursor(e){this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg;for(let t=0;t1;){let s=r.shift(),o=r.shift();if(/^\d+$/.exec(s)){let a=parseInt(s,10);if(Tn(a))if(o==="?")t.push({type:0,index:a});else{let l=ys(o);l&&t.push({type:1,index:a,color:l})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(";");if(t===-1)return!0;let r=e.slice(0,t).trim(),s=e.slice(t+1);return s?this._createHyperlink(r,s):r.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let r=e.split(":"),s,o=r.findIndex(a=>a.startsWith("id="));return o!==-1&&(s=r[o].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let r=e.split(";");for(let s=0;s=this._specialColors.length);++s,++t)if(r[s]==="?")this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let o=ys(r[s]);o&&this._onColor.fire([{type:1,index:this._specialColors[t],color:o}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],r=e.split(";");for(let s=0;s=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=U.clone(),this._eraseAttrDataInternal=U.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new F;e.content=1<<22|69,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`\x1B${l}\x1B\\`),!0),s=this._bufferService.buffer,o=this._optionsService.rawOptions,a={block:2,underline:4,bar:6};return r(e==='"q'?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e==='"p'?'P1$r61;1"p':e==="r"?`P1$r${s.scrollTop+1};${s.scrollBottom+1}r`:e==="m"?"P1$r0m":e===" q"?`P1$r${a[o.cursorStyle]-(o.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}kittyKeyboardSet(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=e.length>1&&e.params[1]||1,s=this._coreService.kittyKeyboard;switch(r){case 1:s.flags=t;break;case 2:s.flags|=t;break;case 3:s.flags&=~t;break}return!0}kittyKeyboardQuery(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=this._coreService.kittyKeyboard.flags;return this._coreService.triggerDataEvent(`\x1B[?${t}u`),!0}kittyKeyboardPush(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=e.params[0]||0,r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;return o.length>=16&&o.shift(),o.push(r.flags),r.flags=t,!0}kittyKeyboardPop(e){if(!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard)return!0;let t=Math.max(1,e.params[0]||1),r=this._coreService.kittyKeyboard,o=this._bufferService.buffer===this._bufferService.buffers.alt?r.altStack:r.mainStack;for(let a=0;a0;a++)r.flags=o.pop();return o.length===0&&t>0&&(r.flags=0),!0}},hi=class{constructor(i){this._bufferService=i;this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(i){ithis.end&&(this.end=i)}markRangeDirty(i,e){i>e&&(wn=i,i=e,e=wn),ithis.end&&(this.end=e)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};hi=y([m(0,D)],hi);function Tn(n){return 0<=n&&n<256}var vr=class extends g{constructor(e){super();this._action=e;this._writeBuffer=[];this._callbacks=[];this._pendingData=0;this._bufferOffset=0;this._isSyncWriting=!1;this._syncCalls=0;this._didUserInput=!1;this._innerWriteTimer=this._register(new Ie);this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._register(E(()=>{this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0}))}handleUserInput(){this._didUserInput=!0}flushSync(){if(this._store.isDisposed||this._isSyncWriting)return;this._isSyncWriting=!0;let e,t=!1;for(;e=this._writeBuffer.shift();){t=!0,this._action(e);let r=this._callbacks.shift();r&&r()}this._pendingData=0,this._bufferOffset=2147483647,this._writeBuffer.length=0,this._callbacks.length=0,this._isSyncWriting=!1,t&&this._onWriteParsed.fire()}writeSync(e,t){if(this._store.isDisposed)return;if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let r;for(;r=this._writeBuffer.shift();){this._action(r);let s=this._callbacks.shift();s&&s()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(!this._store.isDisposed){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}this._scheduleInnerWrite()}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}}_scheduleInnerWrite(e=0,t=!0){this._store.isDisposed||this._innerWriteTimer.cancelAndSet(()=>this._innerWrite(e,t),0)}_innerWrite(e=0,t=!0){if(this._store.isDisposed)return;let r=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let s=this._writeBuffer[this._bufferOffset],o=this._action(s,t);if(o){let l=h=>{this._store.isDisposed||(performance.now()-r>=12?this._scheduleInnerWrite(0,h):this._innerWrite(r,h))};o.catch(h=>(queueMicrotask(()=>{throw h}),Promise.resolve(!1))).then(l);return}let a=this._callbacks[this._bufferOffset];if(a&&a(),this._bufferOffset++,this._pendingData-=s.length,performance.now()-r>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),this._scheduleInnerWrite()):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}};var kt=class{constructor(i){this._bufferService=i;this._nextId=1;this._entriesWithId=new Map;this._dataByLinkId=new Map}registerLink(i){let e=this._bufferService.buffer;if(i.id===void 0){let l=e.addMarker(e.ybase+e.y),h={data:i,id:this._nextId++,lines:[l]};return l.onDispose(()=>this._removeMarkerFromLink(h,l)),this._dataByLinkId.set(h.id,h),h.id}let t=i,r=this._getEntryIdKey(t),s=this._entriesWithId.get(r);if(s)return this.addLineToLink(s.id,e.ybase+e.y),s.id;let o=e.addMarker(e.ybase+e.y),a={id:this._nextId++,key:this._getEntryIdKey(t),data:t,lines:[o]};return o.onDispose(()=>this._removeMarkerFromLink(a,o)),this._entriesWithId.set(a.key,a),this._dataByLinkId.set(a.id,a),a.id}addLineToLink(i,e){let t=this._dataByLinkId.get(i);if(t&&t.lines.every(r=>r.line!==e)){let r=this._bufferService.buffer.addMarker(e);t.lines.push(r),r.onDispose(()=>this._removeMarkerFromLink(t,r))}}getLinkData(i){return this._dataByLinkId.get(i)?.data}_getEntryIdKey(i){return`${i.id};;${i.uri}`}_removeMarkerFromLink(i,e){let t=i.lines.indexOf(e);t!==-1&&(i.lines.splice(t,1),i.lines.length===0&&(i.data.id!==void 0&&this._entriesWithId.delete(i.key),this._dataByLinkId.delete(i.id)))}};kt=y([m(0,D)],kt);var Dn=!1,Sr=class extends g{constructor(e){super();this._windowsWrappingHeuristics=this._register(new P);this._onBinary=this._register(new b);this.onBinary=this._onBinary.event;this._onData=this._register(new b);this.onData=this._onData.event;this._onLineFeed=this._register(new b);this.onLineFeed=this._onLineFeed.event;this._onRender=this._register(new b);this.onRender=this._onRender.event;this._onResize=this._register(new b);this.onResize=this._onResize.event;this._onWriteParsed=this._register(new b);this.onWriteParsed=this._onWriteParsed.event;this._onScroll=this._register(new b);this._instantiationService=new Ji,this.optionsService=this._register(new nr(e)),this._instantiationService.setService(R,this.optionsService),this._logService=this._register(this._instantiationService.createInstance(wt)),this._instantiationService.setService(fe,this._logService),this._bufferService=this._register(this._instantiationService.createInstance(Dt)),this._instantiationService.setService(D,this._bufferService),this.coreService=this._register(this._instantiationService.createInstance(Lt)),this._instantiationService.setService(Y,this.coreService),this.mouseStateService=this._register(this._instantiationService.createInstance(or)),this._instantiationService.setService(Me,this.mouseStateService),this.unicodeService=this._register(this._instantiationService.createInstance(me)),this.unicodeService.register(new ar),this._instantiationService.setService(Us,this.unicodeService),this._charsetService=this._instantiationService.createInstance(lr),this._instantiationService.setService(Ws,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(kt),this._instantiationService.setService(vi,this._oscLinkService),this._inputHandler=this._register(new br(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.mouseStateService,this.unicodeService)),this._register(j.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(j.forward(this._bufferService.onResize,this._onResize)),this._register(j.forward(this.coreService.onData,this._onData)),this._register(j.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange(["windowsPty"],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new vr((t,r)=>this._inputHandler.parse(t,r))),this._register(j.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new b),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Dn&&(this._logService.warn("writeSync is unreliable and will be removed soon."),Dn=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,2),t=Math.max(t,1),this._writeBuffer.flushSync(),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}registerApcHandler(e,t){return this._inputHandler.registerApcHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.mouseStateService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.backend!==void 0&&t.buildNumber!==void 0&&(e=t.backend==="conpty"&&t.buildNumber<21376),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Is.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},()=>(Is(this._bufferService),!1))),this._windowsWrappingHeuristics.value=E(()=>{for(let t of e)t.dispose()})}}};var z=0,gr=class{constructor(i,e){this._getKey=i;this._array=[];this._insertedValues=[];this._isFlushingInserted=!1;this._deletedIndices=[];this._isFlushingDeleted=!1;this._flushInsertedTask=new It(e),this._flushDeletedTask=new It(e)}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(i){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(i)}_flushInserted(){let i=this._insertedValues.sort((s,o)=>this._getKey(s)-this._getKey(o)),e=0,t=0,r=new Array(this._array.length+this._insertedValues.length);for(let s=0;s=this._array.length||this._getKey(i[e])<=this._getKey(this._array[t])?(r[s]=i[e],e++):r[s]=this._array[t++];this._array=r,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(i){if(this._flushCleanupInserted(),this._array.length===0)return!1;let e=this._getKey(i);if(e===void 0)return!1;if(this._deleteAtKey(i,e))return!0;if(this._deletedIndices.length===0)return!1;return this._flushCleanupDeleted(),this._deleteAtKey(i,e)}_deleteAtKey(i,e){if((z=this._search(e),z===-1)||this._getKey(this._array[z])!==e)return!1;do if(this._array[z]===i)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(z),!0;while(++zs-o),e=0,t=new Array(this._array.length-i.length),r=0;for(let s=0;s0&&this._flushDeletedTask.flush()}*getKeyIterator(i){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(z=this._search(i),!(z<0||z>=this._array.length)&&this._getKey(this._array[z])===i))do yield this._array[z];while(++z=this._array.length)&&this._getKey(this._array[z])===i))do e(this._array[z]);while(++z=e;){let r=e+t>>1,s=this._getKey(this._array[r]);if(s>i)t=r-1;else if(s0&&this._getKey(this._array[r-1])===i;)r--;return r}}return e}};var Mt=0,Ir=0,Bt=class extends g{constructor(e,t){super();this._logService=e;this._bufferService=t;this._lineCache=this._register(new xs);this._onDecorationRegistered=this._register(new b);this.onDecorationRegistered=this._onDecorationRegistered.event;this._onDecorationRemoved=this._register(new b);this.onDecorationRemoved=this._onDecorationRemoved.event;this._decorations=new gr(r=>r?.marker.line,this._logService),this._register(E(()=>this.reset())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)})),this._lineCache.attachToBufferLines(this._bufferService.buffer.lines)}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new ws(e);if(t){let r=t.marker.onDispose(()=>t.dispose()),s=t.onDispose(()=>{s.dispose(),t&&(this._decorations.delete(t)&&(this._lineCache.remove(t),this._onDecorationRemoved.fire(t)),r.dispose())});this._decorations.insert(t),this._lineCache.add(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear(),this._lineCache.clear()}*getDecorationsAtCell(e,t,r){let s=this._lineCache.getDecorationsOnLine(t);if(s)for(let o of s)Mt=o.options.x??0,Ir=Mt+(o.options.width??1),e>=Mt&&e=Mt&&ethis._handleBufferLinesTrim(r))),t.add(e.onInsert(r=>this._handleBufferLinesInsert(r))),t.add(e.onDelete(r=>this._handleBufferLinesDelete(r)))}_getDecorationHeight(e){return e.options.height??1}_addToLineBuckets(e){let t=e.marker.line;if(t<0)return;e._indexedStartLine=t;let r=this._getDecorationHeight(e);for(let s=t;s=0&&this._addToLineBuckets(e)}_scheduleLineIndexSync(e){this._lineIndexSyncCallbacks.push(e),this._lineIndexSyncTimer.set(()=>{let t=this._lineIndexSyncCallbacks;this._lineIndexSyncCallbacks=[];for(let r of t)r()})}_handleBufferLinesTrim(e){if(e<=0)return;let t=new Map;for(let[r,s]of this._decorationsByLine){let o=r-e;o<0||this._mergeLineBucket(t,o,s)}this._decorationsByLine.clear();for(let[r,s]of t)this._decorationsByLine.set(r,s);for(let r of this._decorations)r.marker.isDisposed||(r._indexedStartLine-=e)}_handleBufferLinesInsert(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesInsert(e))}_handleBufferLinesDelete(e){this._scheduleLineIndexSync(()=>this._applyBufferLinesDelete(e))}_mergeLineBucket(e,t,r){let s=e.get(t);if(s)for(let o=0,a=r.length;ot&&(s.push(a),this._removeFromLineBuckets(a))}let o=new Map;for(let[a,l]of this._decorationsByLine){let h=a>=t?a+r:a;this._mergeLineBucket(o,h,l)}this._decorationsByLine.clear();for(let[a,l]of o)this._decorationsByLine.set(a,l);for(let a of this._decorations)a.marker.isDisposed||a._indexedStartLine>=t&&(a._indexedStartLine=a.marker.line);for(let a of s)this._addToLineBuckets(a)}_applyBufferLinesDelete(e){let t=e.index+e.amount,r=new Map;for(let[o,a]of this._decorationsByLine){if(o>=e.index&&o=t?o-e.amount:o;this._mergeLineBucket(r,l,a)}this._decorationsByLine.clear();for(let[o,a]of r)this._decorationsByLine.set(o,a);let s=[];for(let o of this._decorations){if(o.marker.isDisposed)continue;let a=o._indexedStartLine,l=this._getDecorationHeight(o);a>=t?o._indexedStartLine=o.marker.line:at&&s.push(o)}for(let o of s)this._reindexDecoration(o)}},ws=class extends pe{constructor(e){super();this.options=e;this.onRenderEmitter=this.add(new b);this.onRender=this.onRenderEmitter.event;this._onDispose=this.add(new b);this.onDispose=this._onDispose.event;this._cachedBg=null;this._cachedFg=null;this.marker=e.marker,this._indexedStartLine=e.marker.line,this.options.overviewRulerOptions&&!this.options.overviewRulerOptions.position&&(this.options.overviewRulerOptions.position="full")}get backgroundColorRGB(){return this._cachedBg===null&&(this.options.backgroundColor?this._cachedBg=B.toColor(this.options.backgroundColor):this._cachedBg=void 0),this._cachedBg}get foregroundColorRGB(){return this._cachedFg===null&&(this.options.foregroundColor?this._cachedFg=B.toColor(this.options.foregroundColor):this._cachedFg=void 0),this._cachedFg}dispose(){this._onDispose.fire(),super.dispose()}};var yo=1e3,Cr=class{constructor(i,e=yo){this._renderCallback=i;this._debounceThresholdMS=e;this._lastRefreshMs=0;this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0),this._additionalRefreshRequested=!1}refresh(i,e,t){this._rowCount=t,i=i??0,e=e??this._rowCount-1,this._rowStart=this._rowStart!==void 0?Math.min(this._rowStart,i):i,this._rowEnd=this._rowEnd!==void 0?Math.max(this._rowEnd,e):e;let r=performance.now();if(r-this._lastRefreshMs>=this._debounceThresholdMS)this._refreshTimeoutID!==void 0&&(clearTimeout(this._refreshTimeoutID),this._refreshTimeoutID=void 0,this._additionalRefreshRequested=!1),this._lastRefreshMs=r,this._innerRefresh();else if(!this._additionalRefreshRequested){let s=r-this._lastRefreshMs,o=this._debounceThresholdMS-s;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},o)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let i=Math.max(this._rowStart,0),e=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(i,e)}};var Rn=!1,Ye=class extends g{constructor(e,t,r,s){super();this._terminal=e;this._coreBrowserService=r;this._renderService=s;this._rowColumns=new WeakMap;this._liveRegionLineCount=0;this._charsToConsume=[];this._charsToAnnounce="";let o=this._coreBrowserService.mainDocument;this._accessibilityContainer=o.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=o.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let a=0;athis._handleBoundaryFocus(a,0),this._bottomBoundaryFocusListener=a=>this._handleBoundaryFocus(a,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=o.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Cr(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");Rn?(this._accessibilityContainer.classList.add("debug"),this._rowContainer.classList.add("debug"),this._debugRootContainer=o.createElement("div"),this._debugRootContainer.classList.add("xterm"),this._debugRootContainer.appendChild(o.createTextNode("------start a11y------")),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(o.createTextNode("------end a11y------")),this._terminal.element.insertAdjacentElement("afterend",this._debugRootContainer)):this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize(a=>this._handleResize(a.rows))),this._register(this._terminal.onRender(a=>this._refreshRows(a.start,a.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(a=>this._handleChar(a))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` `))),this._register(this._terminal.onA11yTab(a=>this._handleTab(a))),this._register(this._terminal.onKey(a=>this._handleKey(a.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register(C(o,"selectionchange",()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register(E(()=>{Rn?this._debugRootContainer.remove():this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` -`&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent=Ze.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){let r=this._terminal.buffer,s=r.lines.length.toString();for(let o=e;o<=t;o++){let a=r.lines.get(r.ydisp+o),l=[],h=a?.translateToString(!0,void 0,void 0,l)||"",d=(r.ydisp+o+1).toString(),c=this._rowElements[o];c&&(h.length===0?(c.textContent="\xA0",this._rowColumns.set(c,[0,1])):(c.textContent=h,this._rowColumns.set(c,l)),c.setAttribute("aria-posinset",d),c.setAttribute("aria-setsize",s),this._alignRowWidth(c))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent===Ze.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){let r=e.target,s=this._rowElements[t===0?1:this._rowElements.length-2],o=r.getAttribute("aria-posinset"),a=t===0?"1":`${this._terminal.buffer.lines.length}`;if(o===a||e.relatedTarget!==s)return;let l,h;if(t===0?(l=r,h=this._rowElements.pop(),this._rowContainer.removeChild(h)):(l=this._rowElements.shift(),h=r,this._rowContainer.removeChild(l)),l.removeEventListener("focus",this._topBoundaryFocusListener),h.removeEventListener("focus",this._bottomBoundaryFocusListener),t===0){let d=this._createAccessibilityTreeNode();this._rowElements.unshift(d),this._rowContainer.insertAdjacentElement("afterbegin",d)}else{let d=this._createAccessibilityTreeNode();this._rowElements.push(d),this._rowContainer.appendChild(d)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(t===0?-1:1),this._rowElements[t===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;let e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed){this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection();return}if(!e.anchorNode||!e.focusNode){console.error("anchorNode and/or focusNode are null");return}let t={node:e.anchorNode,offset:e.anchorOffset},r={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(r.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===r.node&&t.offset>r.offset)&&([t,r]=[r,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;let s=this._rowElements.slice(-1)[0];if(r.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(r={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(r.node))return;let o=({node:h,offset:d})=>{let c=h instanceof Text?h.parentNode:h,u=parseInt(c?.getAttribute("aria-posinset"),10)-1;if(isNaN(u))return console.warn("row is invalid. Race condition?"),null;let _=this._rowColumns.get(c);if(!_)return console.warn("columns is null. Race condition?"),null;let p=d<_.length?_[d]:_.slice(-1)[0]+1;return p>=this._terminal.cols&&(++u,p=0),{row:u,column:p}},a=o(t),l=o(r);if(!(!a||!l)){if(a.row>l.row||a.row===l.row&&a.column>=l.column)throw new Error("invalid range");this._terminal.select(a.column,a.row,(l.row-a.row)*this._terminal.cols-a.column+l.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let t=this._rowContainer.children.length;te;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){let e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register(C(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register(C(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register(C(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register(C(this._element,"mouseup",this._handleMouseUp.bind(this)))}get currentLink(){return this._currentLink}_handleMouseMove(e){this._lastMouseEvent=e;let t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;let r=e.composedPath();for(let s=0;s{s?.forEach(o=>{o.link.dispose&&o.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(let[s,o]of this._linkProviderService.linkProviders.entries())t?this._activeProviderReplies?.get(s)&&(r=this._checkLinkProviderResult(s,e,r)):o.provideLinks(e.y,a=>{if(this._isMouseOut)return;let l=a?.map(h=>({link:h}));this._activeProviderReplies?.set(s,l),r=this._checkLinkProviderResult(s,e,r),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){let r=new Set;for(let s=0;se?this._bufferService.cols:l.link.range.end.x;for(let c=h;c<=d;c++){if(r.has(c)){o.splice(a--,1);break}r.add(c)}}}}_checkLinkProviderResult(e,t,r){if(!this._activeProviderReplies)return r;let s=this._activeProviderReplies.get(e),o=!1;for(let a=0;athis._linkAtPosition(l.link,t));a&&(r=!0,this._handleNewLink(a))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!r)for(let a=0;athis._linkAtPosition(h.link,t));if(l){r=!0,this._handleNewLink(l);break}}return r}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;let t=this._positionFromMouseEvent(e,this._element);t&&this._mouseDownLink&&xo(this._mouseDownLink.link,this._currentLink.link)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){!this._currentLink||!this._lastMouseEvent||(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;let t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:e.link.decorations===void 0?!0:e.link.decorations.underline,pointerCursor:e.link.decorations===void 0?!0:e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:r=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==r&&(this._currentLink.state.decorations.pointerCursor=r,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",r))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:r=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==r&&(this._currentLink.state.decorations.underline=r,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,r))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(r=>{if(!this._currentLink)return;let s=r.start===0?0:r.start+1+this._bufferService.buffer.ydisp,o=this._bufferService.buffer.ydisp+1+r.end;if(this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=o&&(this._clearCurrentLink(s,o),this._lastMouseEvent)){let a=this._positionFromMouseEvent(this._lastMouseEvent,this._element);a&&this._askForLink(a,!1)}})))}_linkHover(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(r,t.text)}_fireUnderlineEvent(e,t){let r=e.range,s=this._bufferService.buffer.ydisp,o=this._createLinkUnderlineEvent(r.start.x-1,r.start.y-s-1,r.end.x,r.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(o)}_linkLeave(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(r,t.text)}_linkAtPosition(e,t){let r=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,o=t.y*this._bufferService.cols+t.x;return r<=o&&o<=s}_positionFromMouseEvent(e,t){let r=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(r)return{x:r[0],y:r[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,r,s,o){return{x1:e,y1:t,x2:r,y2:s,cols:this._bufferService.cols,fg:o}}};Pt=y([m(1,Pe),m(2,V),m(3,D),m(4,Ii)],Pt);function xo(n,i){return n.text===i.text&&n.range.start.x===i.range.start.x&&n.range.start.y===i.range.start.y&&n.range.end.x===i.range.end.x&&n.range.end.y===i.range.end.y}var Er=class extends Sr{constructor(e={}){super(e);this._linkifier=this._register(new P);this.browser=Ke;this._keyDownHandled=!1;this._keyDownSeen=!1;this._keyPressHandled=!1;this._unprocessedDeadKey=!1;this._accessibilityManager=this._register(new P);this._onCursorMove=this._register(new b);this.onCursorMove=this._onCursorMove.event;this._onKey=this._register(new b);this.onKey=this._onKey.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onTitleChange=this._register(new b);this.onTitleChange=this._onTitleChange.event;this._onBell=this._register(new b);this.onBell=this._onBell.event;this._onFocus=this._register(new b);this._onBlur=this._register(new b);this._onA11yCharEmitter=this._register(new b);this._onA11yTabEmitter=this._register(new b);this._onWillOpen=this._register(new b);this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._setup(),this._decorationService=this._instantiationService.createInstance(Bt),this._instantiationService.setService(ge,this._decorationService),this._keyboardService=this._instantiationService.createInstance(xt),this._instantiationService.setService(zs,this._keyboardService),this._linkProviderService=this._instantiationService.createInstance(zi),this._instantiationService.setService(Ii,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(et)),this._register(this._inputHandler.onRequestBell(()=>this._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(t=>this._reportWindowsOptions(t))),this._register(this._inputHandler.onColor(t=>this._handleColorEvent(t))),this._register(j.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(j.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(j.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(j.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(t=>this._afterResize(t.cols,t.rows))),this._register(E(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}get dimensions(){if(!this._renderService)return;let e=this._renderService.dimensions;return{css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}}_handleColorEvent(e){if(this._themeService)for(let t of e){let r,s;switch(t.index){case 256:r="foreground",s="10";break;case 257:r="background",s="11";break;case 258:r="cursor",s="12";break;default:r="ansi",s="4;"+t.index}switch(t.type){case 0:let o=k.toColorRGB(r==="ansi"?this._themeService.colors.ansi[t.index]:this._themeService.colors[r]);this.coreService.triggerDataEvent(`\x1B]${s};${En(o)}\x1B\\`);break;case 1:if(r==="ansi")this._themeService.modifyColors(a=>a.ansi[t.index]=O.toColor(...t.color));else{let a=r;this._themeService.modifyColors(l=>l[a]=O.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index);break}}}_reportColorScheme(){if(!this._themeService)return;let e=Z.relativeLuminance(this._themeService.colors.background.rgba>>8),t=Z.relativeLuminance(this._themeService.colors.foreground.rgba>>8),r=e{this.hasSelection()&&Os(t,this._selectionService)}));let e=t=>Ns(t,this.textarea,this.coreService,this.optionsService);this._register(C(this.textarea,"paste",e)),this._register(C(this.element,"paste",e)),nt?this._register(C(this.element,"mousedown",t=>{t.button===2&&Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register(C(this.element,"contextmenu",t=>{Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),zt&&this._register(C(this.element,"auxclick",t=>{t.button===1&&Fr(t,this.textarea,this.screenElement)}))}_bindKeys(){this._register(C(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register(C(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register(C(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register(C(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register(C(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register(C(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register(C(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService){this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView);return}this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",l=>this.element.classList.toggle("allow-transparency",l))),e.appendChild(this.element);let t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register(C(this.screenElement,"mousemove",l=>this.updateCursorStyle(l))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);let r=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",Ut.get()),Yr||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>r.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(Ki,this.textarea,e.ownerDocument.defaultView??window,this._document??(typeof window<"u"?window.document:null))),this._instantiationService.setService(G,this._coreBrowserService),this._register(C(this.textarea,"focus",l=>this._handleTextAreaFocus(l))),this._register(C(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(bt,this._document,this._helperContainer),this._instantiationService.setService(Be,this._charSizeService),this._themeService=this._instantiationService.createInstance(yt),this._instantiationService.setService(_e,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(He),this._instantiationService.setService(gi,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(Ct,this.rows,this.screenElement)),this._instantiationService.setService(V,this._renderService),this._register(this._renderService.onRenderedViewportChange(l=>this._onRender.fire(l))),this._register(this._renderService.onDimensionsChange(l=>this._onDimensionsChange.fire({css:{canvas:{...l.css.canvas},cell:{...l.css.cell}},device:{canvas:{...l.device.canvas},cell:{...l.device.cell},char:{...l.device.char}}}))),this.onResize(l=>this._renderService.resize(l.cols,l.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(ft,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(vt),this._instantiationService.setService(Pe,this._mouseCoordsService);let s=this._linkifier.value=this._register(this._instantiationService.createInstance(Pt,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(l){this._logService.error("onWillOpen handler threw an exception",l)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(dt,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(l=>{super.scrollLines(l,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(Et,this.element,this.screenElement,s)),this._instantiationService.setService(Si,this._selectionService),this._mouseService=this._instantiationService.createInstance(gt),this._instantiationService.setService(Ks,this._mouseService),this._register(this._selectionService.onRequestScrollLines(l=>this.scrollLines(l.amount,l.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(l=>this._renderService.handleSelectionChanged(l.start,l.end,l.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(l=>{this.textarea.value=l,this.textarea.focus(),this.textarea.select()})),this._register(j.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(ut,this.screenElement)),this._register(C(this.element,"mousedown",l=>this._selectionService.handleMouseDown(l))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(Ye,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",l=>this._handleScreenReaderModeOptionChange(l)));let o=this.options.scrollbar?.showScrollbar??!0,a=this.options.scrollbar?.width;o&&a&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",l=>{let h=(l?.showScrollbar??!0)&&!!l?.width;!this._overviewRulerRenderer&&h&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:l=>this._viewport?.handleTouchScroll(l)},l=>this._register(l),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(mt,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,r=!1){this._renderService?.refreshRows(e,t,r)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}paste(e){Nr(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");let t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return this._selectionService?this._selectionService.hasSelection:!1}select(e,t,r){this._selectionService.setSelection(e,t,r)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(!(!this._selectionService||!this._selectionService.hasSelection))return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;let t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;!t&&(e.key==="Dead"||e.key==="AltGraph")&&(this._unprocessedDeadKey=!0);let r=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),r.type===3||r.type===2){let o=this.rows-1;return this.scrollLines(r.type===2?-o:o),e.preventDefault(),e.stopPropagation(),!1}if(r.type===1&&this.selectAll(),this._isThirdLevelShift(this.browser,e)||(r.cancel&&(e.preventDefault(),e.stopPropagation()),!r.key)||!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;(r.key===""||r.key==="\r")&&(this.textarea.value="");let s=this._keyboardService.useWin32InputMode&&Ts(e);if(this._onKey.fire({key:r.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(r.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){let r=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return t.type==="keypress"?r:r&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return;Ts(e)||this.focus();let t=this._keyboardService.evaluateKeyUp(e);if(t?.key){let r=this._keyboardService.useWin32InputMode&&Ts(e);this.coreService.triggerDataEvent(t.key,!r)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;if(e.charCode)t=e.charCode;else if(e.which===null||e.which===void 0)t=e.keyCode;else if(e.which!==0&&e.charCode!==0)t=e.which;else return!1;return!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)?!1:(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,!0)}_inputEvent(e){if(e.data&&e.inputType==="insertText"&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;let t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){if(e===this.cols&&t===this.rows){this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure();return}super.resize(e,t)}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;i--)this._addons[i].instance.dispose()}loadAddon(i,e){let t={instance:e,dispose:e.dispose,isDisposed:!1};this._addons.push(t),e.dispose=()=>this._wrappedAddonDispose(t),e.activate(i)}_wrappedAddonDispose(i){if(i.isDisposed)return;let e=-1;for(let t=0;t=this._line.length))return e?(this._line.loadCell(i,e),e):this._line.loadCell(i,new F)}translateToString(i,e,t){return this._line.translateToString(i,e,t)}};var di=class{constructor(i,e){this._buffer=i;this.type=e}init(i){return this._buffer=i,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(i){let e=this._buffer.lines.get(i);if(e)return new xr(e)}getNullCell(){return new F}};var wr=class extends g{constructor(e){super();this._core=e;this._onBufferChange=this._register(new b);this.onBufferChange=this._onBufferChange.event;this._normal=new di(this._core.buffers.normal,"normal"),this._alternate=new di(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}};var Tr=class{constructor(i){this._core=i}registerCsiHandler(i,e){return this._core.registerCsiHandler(i,t=>e(t.toArray()))}addCsiHandler(i,e){return this.registerCsiHandler(i,e)}registerDcsHandler(i,e){return this._core.registerDcsHandler(i,(t,r)=>e(t,r.toArray()))}addDcsHandler(i,e){return this.registerDcsHandler(i,e)}registerEscHandler(i,e){return this._core.registerEscHandler(i,e)}addEscHandler(i,e){return this.registerEscHandler(i,e)}registerOscHandler(i,e){return this._core.registerOscHandler(i,e)}addOscHandler(i,e){return this.registerOscHandler(i,e)}registerApcHandler(i,e){return this._core.registerApcHandler(i,e)}};var Dr=class{constructor(i){this._core=i}register(i){this._core.unicodeService.register(i)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(i){this._core.unicodeService.activeVersion=i}};var wo=["cols","rows"],Ee=0,Ln=class extends g{constructor(i){super(),this._core=this._register(new Er(i)),this._addonManager=this._register(new yr),this._publicOptions={...this._core.options};let e=r=>this._core.options[r],t=(r,s)=>{this._checkReadonlyOptions(r),this._core.options[r]=s};for(let r in this._core.options){let s={get:e.bind(this,r),set:t.bind(this,r)};Object.defineProperty(this._publicOptions,r,s)}}_checkReadonlyOptions(i){if(wo.includes(i))throw new Error(`Option "${i}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new Tr(this._core)}get unicode(){return this._checkProposedApi(),new Dr(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new wr(this._core))}get markers(){return this._core.markers}get modes(){let i=this._core.coreService.decPrivateModes,e="none";switch(this._core.mouseStateService.activeProtocol){case"X10":e="x10";break;case"VT200":e="vt200";break;case"DRAG":e="drag";break;case"ANY":e="any";break}return{applicationCursorKeysMode:i.applicationCursorKeys,applicationKeypadMode:i.applicationKeypad,bracketedPasteMode:i.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:e,originMode:i.origin,reverseWraparoundMode:i.reverseWraparound,sendFocusMode:i.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:i.synchronizedOutput,win32InputMode:i.win32InputMode,wraparoundMode:i.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(i){for(let e in i)this._publicOptions[e]=i[e]}blur(){this._core.blur()}focus(){this._core.focus()}input(i,e=!0){this._core.input(i,e)}resize(i,e){this._verifyIntegers(i,e),this._core.resize(i,e)}open(i){this._core.open(i)}attachCustomKeyEventHandler(i){this._core.attachCustomKeyEventHandler(i)}attachCustomWheelEventHandler(i){this._core.attachCustomWheelEventHandler(i)}registerLinkProvider(i){return this._core.registerLinkProvider(i)}registerCharacterJoiner(i){return this._core.registerCharacterJoiner(i)}deregisterCharacterJoiner(i){this._core.deregisterCharacterJoiner(i)}registerMarker(i=0){return this._verifyIntegers(i),this._core.registerMarker(i)}registerDecoration(i){return this._verifyPositiveIntegers(i.x??0,i.width??0,i.height??0),this._core.registerDecoration(i)}hasSelection(){return this._core.hasSelection()}select(i,e,t){this._verifyIntegers(i,e,t),this._core.select(i,e,t)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(i,e){this._verifyIntegers(i,e),this._core.selectLines(i,e)}dispose(){super.dispose()}scrollLines(i){this._verifyIntegers(i),this._core.scrollLines(i)}scrollPages(i){this._verifyIntegers(i),this._core.scrollPages(i)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(i){this._verifyIntegers(i),this._core.scrollToLine(i)}clear(){this._core.clear()}write(i,e){this._core.write(i,e)}writeln(i,e){this._core.write(i),this._core.write(`\r +`&&(this._liveRegionLineCount++,this._liveRegionLineCount===21&&(this._liveRegion.textContent=Ze.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){let r=this._terminal.buffer,s=r.lines.length.toString();for(let o=e;o<=t;o++){let a=r.lines.get(r.ydisp+o),l=[],h=a?.translateToString(!0,void 0,void 0,l)||"",d=(r.ydisp+o+1).toString(),c=this._rowElements[o];c&&(h.length===0?(c.textContent="\xA0",this._rowColumns.set(c,[0,1])):(c.textContent=h,this._rowColumns.set(c,l)),c.setAttribute("aria-posinset",d),c.setAttribute("aria-setsize",s),this._alignRowWidth(c))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent===Ze.get()&&this._clearLiveRegion(),this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){let r=e.target,s=this._rowElements[t===0?1:this._rowElements.length-2],o=r.getAttribute("aria-posinset"),a=t===0?"1":`${this._terminal.buffer.lines.length}`;if(o===a||e.relatedTarget!==s)return;let l,h;if(t===0?(l=r,h=this._rowElements.pop(),this._rowContainer.removeChild(h)):(l=this._rowElements.shift(),h=r,this._rowContainer.removeChild(l)),l.removeEventListener("focus",this._topBoundaryFocusListener),h.removeEventListener("focus",this._bottomBoundaryFocusListener),t===0){let d=this._createAccessibilityTreeNode();this._rowElements.unshift(d),this._rowContainer.insertAdjacentElement("afterbegin",d)}else{let d=this._createAccessibilityTreeNode();this._rowElements.push(d),this._rowContainer.appendChild(d)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(t===0?-1:1),this._rowElements[t===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;let e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed){this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection();return}if(!e.anchorNode||!e.focusNode){console.error("anchorNode and/or focusNode are null");return}let t={node:e.anchorNode,offset:e.anchorOffset},r={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(r.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===r.node&&t.offset>r.offset)&&([t,r]=[r,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;let s=this._rowElements.slice(-1)[0];if(r.node.compareDocumentPosition(s)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(r={node:s,offset:s.textContent?.length??0}),!this._rowContainer.contains(r.node))return;let o=({node:h,offset:d})=>{let c=h instanceof Text?h.parentNode:h,u=parseInt(c?.getAttribute("aria-posinset"),10)-1;if(isNaN(u))return console.warn("row is invalid. Race condition?"),null;let _=this._rowColumns.get(c);if(!_)return console.warn("columns is null. Race condition?"),null;let p=d<_.length?_[d]:_.slice(-1)[0]+1;return p>=this._terminal.cols&&(++u,p=0),{row:u,column:p}},a=o(t),l=o(r);if(!(!a||!l)){if(a.row>l.row||a.row===l.row&&a.column>=l.column)throw new Error("invalid range");this._terminal.select(a.column,a.row,(l.row-a.row)*this._terminal.cols-a.column+l.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let t=this._rowContainer.children.length;te;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){let e=this._coreBrowserService.mainDocument.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register(C(this._element,"mouseleave",()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register(C(this._element,"mousemove",this._handleMouseMove.bind(this))),this._register(C(this._element,"mousedown",this._handleMouseDown.bind(this))),this._register(C(this._element,"mouseup",this._handleMouseUp.bind(this)))}get currentLink(){return this._currentLink}_handleMouseMove(e){this._lastMouseEvent=e;let t=this._positionFromMouseEvent(e,this._element);if(!t)return;this._isMouseOut=!1;let r=e.composedPath();for(let s=0;s{s?.forEach(o=>{o.link.dispose&&o.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(let[s,o]of this._linkProviderService.linkProviders.entries())t?this._activeProviderReplies?.get(s)&&(r=this._checkLinkProviderResult(s,e,r)):o.provideLinks(e.y,a=>{if(this._isMouseOut)return;let l=a?.map(h=>({link:h}));this._activeProviderReplies?.set(s,l),r=this._checkLinkProviderResult(s,e,r),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){let r=new Set;for(let s=0;se?this._bufferService.cols:l.link.range.end.x;for(let c=h;c<=d;c++){if(r.has(c)){o.splice(a--,1);break}r.add(c)}}}}_checkLinkProviderResult(e,t,r){if(!this._activeProviderReplies)return r;let s=this._activeProviderReplies.get(e),o=!1;for(let a=0;athis._linkAtPosition(l.link,t));a&&(r=!0,this._handleNewLink(a))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!r)for(let a=0;athis._linkAtPosition(h.link,t));if(l){r=!0,this._handleNewLink(l);break}}return r}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;let t=this._positionFromMouseEvent(e,this._element);t&&this._mouseDownLink&&xo(this._mouseDownLink.link,this._currentLink.link)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){!this._currentLink||!this._lastMouseEvent||(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,Oe(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;let t=this._positionFromMouseEvent(this._lastMouseEvent,this._element);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:e.link.decorations===void 0?!0:e.link.decorations.underline,pointerCursor:e.link.decorations===void 0?!0:e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:r=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==r&&(this._currentLink.state.decorations.pointerCursor=r,this._currentLink.state.isHovered&&this._element.classList.toggle("xterm-cursor-pointer",r))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:r=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==r&&(this._currentLink.state.decorations.underline=r,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,r))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(r=>{if(!this._currentLink)return;let s=r.start===0?0:r.start+1+this._bufferService.buffer.ydisp,o=this._bufferService.buffer.ydisp+1+r.end;if(this._currentLink.link.range.start.y>=s&&this._currentLink.link.range.end.y<=o&&(this._clearCurrentLink(s,o),this._lastMouseEvent)){let a=this._positionFromMouseEvent(this._lastMouseEvent,this._element);a&&this._askForLink(a,!1)}})))}_linkHover(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(r,t.text)}_fireUnderlineEvent(e,t){let r=e.range,s=this._bufferService.buffer.ydisp,o=this._createLinkUnderlineEvent(r.start.x-1,r.start.y-s-1,r.end.x,r.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(o)}_linkLeave(e,t,r){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(r,t.text)}_linkAtPosition(e,t){let r=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,o=t.y*this._bufferService.cols+t.x;return r<=o&&o<=s}_positionFromMouseEvent(e,t){let r=this._mouseCoordsService.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(r)return{x:r[0],y:r[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,r,s,o){return{x1:e,y1:t,x2:r,y2:s,cols:this._bufferService.cols,fg:o}}};Pt=y([m(1,Pe),m(2,V),m(3,D),m(4,Ii)],Pt);function xo(n,i){return n.text===i.text&&n.range.start.x===i.range.start.x&&n.range.start.y===i.range.start.y&&n.range.end.x===i.range.end.x&&n.range.end.y===i.range.end.y}var Er=class extends Sr{constructor(e={}){super(e);this._linkifier=this._register(new P);this.browser=Ke;this._keyDownHandled=!1;this._keyDownSeen=!1;this._keyPressHandled=!1;this._unprocessedDeadKey=!1;this._accessibilityManager=this._register(new P);this._onCursorMove=this._register(new b);this.onCursorMove=this._onCursorMove.event;this._onKey=this._register(new b);this.onKey=this._onKey.event;this._onSelectionChange=this._register(new b);this.onSelectionChange=this._onSelectionChange.event;this._onTitleChange=this._register(new b);this.onTitleChange=this._onTitleChange.event;this._onBell=this._register(new b);this.onBell=this._onBell.event;this._onFocus=this._register(new b);this._onBlur=this._register(new b);this._onA11yCharEmitter=this._register(new b);this._onA11yTabEmitter=this._register(new b);this._onWillOpen=this._register(new b);this._onDimensionsChange=this._register(new b);this.onDimensionsChange=this._onDimensionsChange.event;this._setup(),this._decorationService=this._instantiationService.createInstance(Bt),this._instantiationService.setService(ge,this._decorationService),this._keyboardService=this._instantiationService.createInstance(xt),this._instantiationService.setService(zs,this._keyboardService),this._linkProviderService=this._instantiationService.createInstance(zi),this._instantiationService.setService(Ii,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(et)),this._register(this._inputHandler.onRequestBell(()=>this._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(t=>this.refresh(t?.start??0,t?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(t=>this._reportWindowsOptions(t))),this._register(this._inputHandler.onColor(t=>this._handleColorEvent(t))),this._register(j.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(j.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(j.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(j.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(t=>this._afterResize(t.cols,t.rows))),this._register(E(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}get dimensions(){if(!this._renderService)return;let e=this._renderService.dimensions;return{css:{canvas:{...e.css.canvas},cell:{...e.css.cell}},device:{canvas:{...e.device.canvas},cell:{...e.device.cell},char:{...e.device.char}}}}_handleColorEvent(e){if(this._themeService)for(let t of e){let r,s;switch(t.index){case 256:r="foreground",s="10";break;case 257:r="background",s="11";break;case 258:r="cursor",s="12";break;default:r="ansi",s="4;"+t.index}switch(t.type){case 0:let o=k.toColorRGB(r==="ansi"?this._themeService.colors.ansi[t.index]:this._themeService.colors[r]);this.coreService.triggerDataEvent(`\x1B]${s};${En(o)}\x1B\\`);break;case 1:if(r==="ansi")this._themeService.modifyColors(a=>a.ansi[t.index]=O.toColor(...t.color));else{let a=r;this._themeService.modifyColors(l=>l[a]=O.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index);break}}}_reportColorScheme(){if(!this._themeService)return;let e=Z.relativeLuminance(this._themeService.colors.background.rgba>>8),t=Z.relativeLuminance(this._themeService.colors.foreground.rgba>>8),r=e{this.hasSelection()&&Os(t,this._selectionService)}));let e=t=>Ns(t,this.textarea,this.coreService,this.optionsService);this._register(C(this.textarea,"paste",e)),this._register(C(this.element,"paste",e)),nt?this._register(C(this.element,"mousedown",t=>{t.button===2&&Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register(C(this.element,"contextmenu",t=>{Hr(t,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),zt&&this._register(C(this.element,"auxclick",t=>{t.button===1&&Fr(t,this.textarea,this.screenElement)}))}_bindKeys(){this._register(C(this.textarea,"keyup",e=>this._keyUp(e),!0)),this._register(C(this.textarea,"keydown",e=>this._keyDown(e),!0)),this._register(C(this.textarea,"keypress",e=>this._keyPress(e),!0)),this._register(C(this.textarea,"compositionstart",()=>{this._syncTextArea(),this._compositionHelper.compositionstart(),this._compositionHelper.updateCompositionElements()})),this._register(C(this.textarea,"compositionupdate",e=>this._compositionHelper.compositionupdate(e))),this._register(C(this.textarea,"compositionend",()=>this._compositionHelper.compositionend())),this._register(C(this.textarea,"input",e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw new Error("Terminal requires a parent element.");if(e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this.element?.ownerDocument.defaultView&&this._coreBrowserService){this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView);return}this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),this.element.classList.toggle("allow-transparency",this.options.allowTransparency),this._register(this.optionsService.onSpecificOptionChange("allowTransparency",l=>this.element.classList.toggle("allow-transparency",l))),e.appendChild(this.element);let t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._register(C(this.screenElement,"mousemove",l=>this.updateCursorStyle(l))),this._helperContainer=this._document.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);let r=this.textarea=this._document.createElement("textarea");this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",Ut.get()),Yr||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange("disableStdin",()=>r.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(Ki,this.textarea,e.ownerDocument.defaultView??window,this._document??(typeof window<"u"?window.document:null))),this._instantiationService.setService(G,this._coreBrowserService),this._register(C(this.textarea,"focus",l=>this._handleTextAreaFocus(l))),this._register(C(this.textarea,"blur",()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(bt,this._document,this._helperContainer),this._instantiationService.setService(Be,this._charSizeService),this._themeService=this._instantiationService.createInstance(yt),this._instantiationService.setService(_e,this._themeService),this._register(this._inputHandler.onRequestColorSchemeQuery(()=>this._reportColorScheme())),this._register(this._themeService.onChangeColors(()=>{this.coreService.decPrivateModes.colorSchemeUpdates&&this._reportColorScheme()})),this._characterJoinerService=this._instantiationService.createInstance(He),this._instantiationService.setService(gi,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(Ct,this.rows,this.screenElement)),this._instantiationService.setService(V,this._renderService),this._register(this._renderService.onRenderedViewportChange(l=>this._onRender.fire(l))),this._register(this._renderService.onDimensionsChange(l=>this._onDimensionsChange.fire({css:{canvas:{...l.css.canvas},cell:{...l.css.cell}},device:{canvas:{...l.device.canvas},cell:{...l.device.cell},char:{...l.device.char}}}))),this.onResize(l=>this._renderService.resize(l.cols,l.rows)),this._compositionView=this._document.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(ft,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseCoordsService=this._instantiationService.createInstance(vt),this._instantiationService.setService(Pe,this._mouseCoordsService);let s=this._linkifier.value=this._register(this._instantiationService.createInstance(Pt,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch(l){this._logService.error("onWillOpen handler threw an exception",l)}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>{this._renderService.handleResize(this.cols,this.rows),this._syncTextArea()})),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(dt,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(l=>{super.scrollLines(l,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(Et,this.element,this.screenElement,s)),this._instantiationService.setService(Si,this._selectionService),this._mouseService=this._instantiationService.createInstance(gt),this._instantiationService.setService(Ks,this._mouseService),this._register(this._selectionService.onRequestScrollLines(l=>this.scrollLines(l.amount,l.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(l=>this._renderService.handleSelectionChanged(l.start,l.end,l.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(l=>{this.textarea.value=l,this.textarea.focus(),this.textarea.select()})),this._register(j.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(ut,this.screenElement)),this._register(C(this.element,"mousedown",l=>this._selectionService.handleMouseDown(l))),this.mouseStateService.areMouseEventsActive&&!this.options.mouseEventsRequireAlt?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):(this._selectionService.enable(),this.element.classList.remove("enable-mouse-events")),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(Ye,this)),this._register(this.optionsService.onSpecificOptionChange("screenReaderMode",l=>this._handleScreenReaderModeOptionChange(l)));let o=this.options.scrollbar?.showScrollbar??!0,a=this.options.scrollbar?.width;o&&a&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("scrollbar",l=>{let h=(l?.showScrollbar??!0)&&!!l?.width;!this._overviewRulerRenderer&&h&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(ze,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this._mouseService.bindMouse({element:this.element,screenElement:this.screenElement,document:this._document,handleTouchScroll:l=>this._viewport?.handleTouchScroll(l)},l=>this._register(l),()=>this.focus())}_createRenderer(){return this._instantiationService.createInstance(mt,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}refresh(e,t,r=!1){this._renderService?.refreshRows(e,t,r)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}paste(e){Nr(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this.mouseStateService.setCustomWheelEventHandler(e)}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");let t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return this._selectionService?this._selectionService.hasSelection:!1}select(e,t,r){this._selectionService.setSelection(e,t,r)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(!(!this._selectionService||!this._selectionService.hasSelection))return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;let t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;!t&&(e.key==="Dead"||e.key==="AltGraph")&&(this._unprocessedDeadKey=!0);let r=this._keyboardService.evaluateKeyDown(e);if(this.updateCursorStyle(e),r.type===3||r.type===2){let o=this.rows-1;return this.scrollLines(r.type===2?-o:o),e.preventDefault(),e.stopPropagation(),!1}if(r.type===1&&this.selectAll(),this._isThirdLevelShift(this.browser,e)||(r.cancel&&(e.preventDefault(),e.stopPropagation()),!r.key)||!this._keyboardService.useKitty&&!this._keyboardService.useWin32InputMode&&e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;(r.key===""||r.key==="\r")&&(this.textarea.value="");let s=this._keyboardService.useWin32InputMode&&Ts(e);if(this._onKey.fire({key:r.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(r.key,!s),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return e.preventDefault(),e.stopPropagation(),!1;this._keyDownHandled=!0}_isThirdLevelShift(e,t){let r=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return t.type==="keypress"?r:r&&(!t.keyCode||t.keyCode>47)}_keyUp(e){if(this._keyDownSeen=!1,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return;Ts(e)||this.focus();let t=this._keyboardService.evaluateKeyUp(e);if(t?.key){let r=this._keyboardService.useWin32InputMode&&Ts(e);this.coreService.triggerDataEvent(t.key,!r)}this.updateCursorStyle(e),this._keyPressHandled=!1}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;if(e.charCode)t=e.charCode;else if(e.which===null||e.which===void 0)t=e.keyCode;else if(e.which!==0&&e.charCode!==0)t=e.which;else return!1;return!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)?!1:(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this._compositionHelper.keypress(t)||this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,!0)}_inputEvent(e){if(e.data&&e.inputType==="insertText"&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;let t=e.data;return this.coreService.triggerDataEvent(t,!0),!0}return!1}resize(e,t){if(e===this.cols&&t===this.rows){this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure();return}super.resize(e,t)}_afterResize(e,t){this._charSizeService?.measure()}clear(){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;i--)this._addons[i].instance.dispose()}loadAddon(i,e){let t={instance:e,dispose:e.dispose,isDisposed:!1};this._addons.push(t),e.dispose=()=>this._wrappedAddonDispose(t),e.activate(i)}_wrappedAddonDispose(i){if(i.isDisposed)return;let e=-1;for(let t=0;t=this._line.length))return e?(this._line.loadCell(i,e),e):this._line.loadCell(i,new F)}translateToString(i,e,t){return this._line.translateToString(i,e,t)}};var di=class{constructor(i,e){this._buffer=i;this.type=e}init(i){return this._buffer=i,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(i){let e=this._buffer.lines.get(i);if(e)return new xr(e)}getNullCell(){return new F}};var wr=class extends g{constructor(e){super();this._core=e;this._onBufferChange=this._register(new b);this.onBufferChange=this._onBufferChange.event;this._normal=new di(this._core.buffers.normal,"normal"),this._alternate=new di(this._core.buffers.alt,"alternate"),this._register(this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}};var Tr=class{constructor(i){this._core=i}registerCsiHandler(i,e){return this._core.registerCsiHandler(i,t=>e(t.toArray()))}addCsiHandler(i,e){return this.registerCsiHandler(i,e)}registerDcsHandler(i,e){return this._core.registerDcsHandler(i,(t,r)=>e(t,r.toArray()))}addDcsHandler(i,e){return this.registerDcsHandler(i,e)}registerEscHandler(i,e){return this._core.registerEscHandler(i,e)}addEscHandler(i,e){return this.registerEscHandler(i,e)}registerOscHandler(i,e){return this._core.registerOscHandler(i,e)}addOscHandler(i,e){return this.registerOscHandler(i,e)}registerApcHandler(i,e){return this._core.registerApcHandler(i,e)}};var Dr=class{constructor(i){this._core=i}register(i){this._core.unicodeService.register(i)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(i){this._core.unicodeService.activeVersion=i}};var wo=["cols","rows"],Ee=0,Ln=class extends g{constructor(i){super(),this._core=this._register(new Er(i)),this._addonManager=this._register(new yr),this._publicOptions={...this._core.options};let e=r=>this._core.options[r],t=(r,s)=>{this._checkReadonlyOptions(r),this._core.options[r]=s};for(let r in this._core.options){let s={get:e.bind(this,r),set:t.bind(this,r)};Object.defineProperty(this._publicOptions,r,s)}}_checkReadonlyOptions(i){if(wo.includes(i))throw new Error(`Option "${i}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get onDimensionsChange(){return this._core.onDimensionsChange}get element(){return this._core.element}get screenElement(){return this._core.screenElement}get parser(){return this._parser??=new Tr(this._core)}get unicode(){return this._checkProposedApi(),new Dr(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer??=this._register(new wr(this._core))}get markers(){return this._core.markers}get modes(){let i=this._core.coreService.decPrivateModes,e="none";switch(this._core.mouseStateService.activeProtocol){case"X10":e="x10";break;case"VT200":e="vt200";break;case"DRAG":e="drag";break;case"ANY":e="any";break}return{applicationCursorKeysMode:i.applicationCursorKeys,applicationKeypadMode:i.applicationKeypad,bracketedPasteMode:i.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:e,originMode:i.origin,reverseWraparoundMode:i.reverseWraparound,sendFocusMode:i.sendFocus,showCursor:!this._core.coreService.isCursorHidden,synchronizedOutputMode:i.synchronizedOutput,win32InputMode:i.win32InputMode,wraparoundMode:i.wraparound}}get dimensions(){return this._core.dimensions}get options(){return this._publicOptions}set options(i){for(let e in i)this._publicOptions[e]=i[e]}blur(){this._core.blur()}focus(){this._core.focus()}input(i,e=!0){this._core.input(i,e)}resize(i,e){this._verifyIntegers(i,e),this._core.resize(i,e)}open(i){this._core.open(i)}attachCustomKeyEventHandler(i){this._core.attachCustomKeyEventHandler(i)}attachCustomWheelEventHandler(i){this._core.attachCustomWheelEventHandler(i)}registerLinkProvider(i){return this._core.registerLinkProvider(i)}registerCharacterJoiner(i){return this._core.registerCharacterJoiner(i)}deregisterCharacterJoiner(i){this._core.deregisterCharacterJoiner(i)}registerMarker(i=0){return this._verifyIntegers(i),this._core.registerMarker(i)}registerDecoration(i){return this._verifyPositiveIntegers(i.x??0,i.width??0,i.height??0),this._core.registerDecoration(i)}hasSelection(){return this._core.hasSelection()}select(i,e,t){this._verifyIntegers(i,e,t),this._core.select(i,e,t)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(i,e){this._verifyIntegers(i,e),this._core.selectLines(i,e)}dispose(){super.dispose()}scrollLines(i){this._verifyIntegers(i),this._core.scrollLines(i)}scrollPages(i){this._verifyIntegers(i),this._core.scrollPages(i)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(i){this._verifyIntegers(i),this._core.scrollToLine(i)}clear(){this._core.clear()}write(i,e){this._core.write(i,e)}writeln(i,e){this._core.write(i),this._core.write(`\r @@ -30,16 +31,16 @@ index 4557e1652c34737fdf853436bd9328d9918eee2b..5c60ecbf14aaa8ecec5e5beb2a3e4790 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -1008,7 +1008,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { - + this._onKey.fire({ key, domEvent: ev }); this._showCursor(); - this.coreService.triggerDataEvent(key, true); + if (!this._compositionHelper!.keypress(key)) { + this.coreService.triggerDataEvent(key, true); + } - + this._keyPressHandled = true; - + diff --git a/src/browser/Types.ts b/src/browser/Types.ts index 497afcf535f3eaca00889525a77e15eb633ccd96..c9fc2cf1c06d86cf5459eae1fadc8dce6b4c753b 100644 --- a/src/browser/Types.ts @@ -50,16 +51,16 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..c9fc2cf1c06d86cf5459eae1fadc8dce keydown(ev: KeyboardEvent): boolean; + keypress(text: string): boolean; } - + export interface IBrowser { diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts -index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07ea430c3f2 100644 +index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..8a6db2f530bada4fd69a614287aadd499d611c95 100644 --- a/src/browser/input/CompositionHelper.ts +++ b/src/browser/input/CompositionHelper.ts @@ -47,6 +47,11 @@ export class CompositionHelper { */ private _dataAlreadySent: string; - + + /** + * Keypress text waiting to be reconciled with the textarea composition candidate. + */ @@ -74,12 +75,12 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e this._dataAlreadySent = ''; + this._pendingKeypressData = ''; } - + /** @@ -138,6 +144,18 @@ export class CompositionHelper { return true; } - + + /** + * Defers keypress text while a composition finalizer is pending so all input is emitted once + * after reconciliation with the final textarea candidate. @@ -109,7 +110,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e }; const currentCompositionSuffix = this._compositionSuffix; + this._pendingKeypressData = ''; - + // Since composition* events happen before the changes take place in the textarea on most // browsers, use a setTimeout with 0ms time to allow the native compositionend event to @@ -195,14 +214,39 @@ export class CompositionHelper { @@ -124,7 +125,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e }, 0); } } - + + private _sendCompositionInput(input: string): void { + const keypress = this._pendingKeypressData; + // Why: Chromium may copy keypress text anywhere into the candidate, or expose only an @@ -155,3 +156,32 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..c93d6d8b35dfd7318444147cec12f07e /** * Apply any changes made to the textarea after the current event chain is allowed to complete. * This should be called when not currently composing but a keydown event with the "composition +diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts +index 8a10076e3963e33b4a7d1e4602333eb3f4772dc9..df0761c35907ddc48eb102ba181b0dac8e61f00d 100644 +--- a/src/common/SortedList.ts ++++ b/src/common/SortedList.ts +@@ -87,6 +87,24 @@ export class SortedList { + if (key === undefined) { + return false; + } ++ if (this._deleteAtKey(value, key)) { ++ return true; ++ } ++ // A pending deletion whose key mutated after `delete()` (disposing a marker ++ // resets `line` to -1, and `line` is the sort key) leaves `_array` out of ++ // order, so the binary search above can miss a value that is present. ++ // Compacting those entries out restores the order; retry before reporting ++ // the value absent, else its `onDecorationRemoved` never fires and the ++ // decoration paints forever. Miss path only, so the common bulk delete ++ // keeps its O(log n) search and deferred-compaction batching. ++ if (this._deletedIndices.length === 0) { ++ return false; ++ } ++ this._flushCleanupDeleted(); ++ return this._deleteAtKey(value, key); ++ } ++ ++ private _deleteAtKey(value: T, key: number): boolean { + i = this._search(key); + if (i === -1) { + return false; diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index f5200abb1a5..ba6ecb6cf91 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "updatedAt": "2026-07-25", + "updatedAt": "2026-07-26", "policy": { "maturityLevels": [ "experimental", @@ -141,6 +141,108 @@ ], "demotionRule": "Keep experimental or demote if focused tests flake, HOST_OFFLINE can park indefinitely, direct probes serialize configured endpoints, loser cleanup leaks clients, or physical-device radio cost exceeds the measured budget." }, + { + "id": "desktop-relay.assignment-backpressure", + "title": "Desktop relay drain recovery cannot amplify a director outage", + "maturity": "experimental", + "protection": "partial", + "owner": "desktop-runtime", + "layer": "main-relay-state-machine", + "surfaces": [ + "desktop relay drain recovery", + "director assignment overload", + "relay broker shutdown" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "cloud-relay" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "cloud-relay" + ], + "coverageNotes": "Deterministic main-process tests cover duplicate drain notifications, full-jitter backoff, Retry-After during initial setup and drain recovery, successful recovery, and broker-close cleanup. Packaged desktop, mixed-version fleets, GFE, and production Cloud SQL remain live-test gaps.", + "motivatingLinks": [ + "https://github.com/stablyai/orca-cloud/actions/runs/30223521062" + ], + "invariant": "One relay host may have at most one assignment attempt or retry timer per recovery path. Sustained director failure must increase the retry window up to five minutes, a bounded Retry-After must be respected during initial setup and drain recovery, shutdown must cancel pending work, and recovery must activate the authoritative assigned origin.", + "oracle": "Inject duplicate drain events, deterministic randomness, fake time, repeated assignment failures, a 30-second Retry-After during initial setup and drain recovery, broker close, and eventual director recovery. Count every assignment call, require 500 ms then 1,000 ms retry windows, reject duplicate fanout, require no pre-hint retry or post-close work, and prove the recovered cell becomes authoritative.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/relay/relay-session-broker.test.ts src/main/runtime/relay/relay-http-client.test.ts src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts --reporter=dot" + ], + "testFiles": [ + "src/main/runtime/relay/relay-session-broker.test.ts", + "src/main/runtime/relay/relay-http-client.test.ts", + "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/runtime/relay/relay-session-broker.test.ts", + "assertions": [ + "duplicate drain notifications share one exponentially backed-off retry schedule", + "Retry-After suppresses early assignment requests", + "broker close prevents retry resurrection", + "a later successful assignment activates the new origin" + ] + }, + { + "file": "src/main/runtime/relay/relay-http-client.test.ts", + "assertions": [ + "assignment overload preserves a bounded Retry-After hint" + ] + }, + { + "file": "src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts", + "assertions": [ + "initial relay setup does not retry before Retry-After expires" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-26", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/relay/relay-session-broker.test.ts src/main/runtime/relay/relay-http-client.test.ts src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts --reporter=dot", + "result": "passed", + "durationSeconds": 0.49, + "summary": "Three focused relay files passed with 25 assertions." + } + ], + "runtimeBudget": { + "p95Seconds": 5, + "scope": "focused desktop relay state-machine tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "One deterministic local run exists; CI and soak history are not yet available." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "The prior fixed-delay implementation issued a duplicate assignment within 499 ms and ignored Retry-After, while the candidate passes the byte-identical timer and call-count oracle." + }, + "performanceBudget": { + "required": true, + "evidence": "One host retains at most one assignment attempt or retry timer, retry windows grow to a five-minute cap, duplicate drain events add no calls, and close leaves no timer-driven work." + }, + "promotionCriteria": [ + "Collect 100 consecutive CI passes or 14 days of soak history.", + "Run a mixed-version load test with at least the incident-scale desktop population.", + "Verify production director request rate decays during an injected assignment outage." + ], + "knownGaps": [ + "No packaged desktop or physical phone was exercised.", + "The deterministic seam does not measure production GFE, carrier NAT, DNS, TLS, or Cloud SQL behavior.", + "Legacy desktop versions remain dependent on server-side overload protection." + ], + "demotionRule": "Keep experimental or demote if assignment calls overlap, duplicate drain events bypass backoff, Retry-After is ignored, close resurrects work, or mixed-version request rate exceeds the reviewed director budget." + }, { "id": "git-worktree.refresh-event-semantics", "title": "Index-only Git metadata cannot trigger structural worktree refresh fanout", @@ -1030,47 +1132,77 @@ }, { "id": "terminal-session.daemon-generation-reconnect-safety", - "title": "Reconnect lifecycle echoes cannot kill live daemon-generation terminals", + "title": "Negotiated close intent protects live daemon-generation terminals", "maturity": "experimental", "protection": "partial", "owner": "terminal-runtime", - "layer": "renderer-runtime-rpc-windows-daemon-contract", + "layer": "renderer-runtime-rpc-daemon-contract", "surfaces": [ "runtime session reconnect", "legacy daemon adoption", + "mixed-version paired viewer close", "terminal lifecycle close", "app relaunch and profile reconnect" ], "platforms": ["macos", "linux", "windows"], "providers": ["daemon", "runtime", "ssh", "wsl"], - "coveredPlatforms": ["windows"], + "coveredPlatforms": ["linux", "macos", "windows"], "coveredProviders": ["daemon", "runtime"], - "coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The current harness additionally includes the v26 agent-authority boundary while retaining v24 clean-disconnect and v25 startup-ingress coverage; that six-generation Windows rerun remains to be collected. Deterministic host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, authenticated legacy and unattributed intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. Docker is unavailable and WSL is not installed on this runner, so live SSH/WSL remain gaps.", + "coverageNotes": "Recorded native Windows evidence covers the v21/v22/v23/v24/v25 named-pipe matrix. The deterministic daemon harness covers capable and legacy paired-runtime request shapes against live v25/v26 PTYs in separate worktrees, plus an unrelated control. Host/renderer tests cover old servers, missing liveness, stale publications, reused claims, split parents, explicit user intent, cross-profile isolation, remote runtime clients, SSH-provider routing, and WSL boundaries. A headed host paired to a separate live client, headless serve parity, Linux, SSH, and WSL remain explicit gaps.", "motivatingLinks": [ "https://github.com/stablyai/orca/issues/9749", + "https://github.com/stablyai/orca/issues/9949", "https://github.com/stablyai/orca/issues/8871", "https://github.com/stablyai/orca/issues/9138", "https://github.com/stablyai/orca/issues/9229" ], - "invariant": "Reconnect, replay, or lifecycle observations must never kill a live PTY. Destructive close requires explicit user intent; lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Missing or incompatible evidence keeps and audits. Legacy daemon hello and warm reattachment remain non-destructive.", - "oracle": "Start six isolated native-Windows daemon generations on distinct versioned named pipes, let the production desktop scanner discover v21-v25 from a v26 client, attach live and stale-mirror canaries with exact root/descendant PID-start identities, reconnect and relaunch the production router path, issue repeated desktop and remote-profile lifecycle closes, and require every daemon, root, and descendant to remain alive with zero session-killed events. Unit contracts require unknown/stale/reused/cross-profile claims and live PTYs to refuse without kill or renderer-close calls, old servers to return method_not_found with no destructive fallback, authenticated legacy and explicit user closes to remain destructive, and dead whole-headless state to retire without signalling its retained PTY id.", + "invariant": "Reconnect, replay, or lifecycle observations from a viewer that negotiated explicit close intent must never kill a live PTY. A capable reasonless close must keep and republish; a legacy paired viewer must retain current-main behavior because its intentional close and cleanup echo are wire-identical. Lifecycle close requires the exact observed publication, terminal, environment, and authoritative liveness, never signals a process, and leaves renderer-owned or partial-split retirement to its owner. Legacy daemon hello and warm reattachment remain non-destructive.", + "oracle": "Start isolated v25 and v26 daemon generations with one capable-viewer PTY and one legacy-viewer PTY per generation in four target worktrees plus an unaddressed control PTY in a fifth worktree. Route them through the production desktop scanner, runtime, RPC dispatcher, renderer-close relay, and daemon router. First issue sequential reasonless closes from an authenticated capable connection and require refusal, snapshot republish, zero shutdown calls, exact process survival, and post-close I/O. Then issue the byte-identical requests without the negotiated capability and require current-main behavior: two ordered immediate shutdowns, session-killed events, and exact root/descendant death, while capable and control PTYs survive. An observer lists all targets before, between, and after while issuing zero closes. Unit contracts also require in-process reasonless refusal, legacy runtime/mobile compatibility, explicit user closes, encrypted client-auth advertisement, and old-server lifecycle calls never to fall back.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", - "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" + "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/remote-runtime-request-connection.integration.test.ts src/main/runtime/rpc/methods/session-tabs.test.ts src/main/runtime/rpc/methods/session-tabs-schemas.test.ts src/main/runtime/rpc/e2ee-channel.test.ts src/main/runtime/rpc/e2ee-channel-v2.test.ts src/main/runtime/rpc/mobile-socket-wiring.test.ts src/main/runtime/rpc/runtime-client-capabilities.test.ts src/shared/remote-runtime-client.test.ts src/shared/remote-runtime-request-connection.test.ts src/shared/remote-runtime-shared-control-connection.test.ts src/cli/runtime/websocket-transport.test.ts src/renderer/src/web/web-runtime-client.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-close-intent.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/components/terminal/terminal-tab-actions.test.ts src/renderer/src/components/terminal/terminal-close-incarnation.test.ts src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", + "pnpm exec playwright test tests/e2e/daemon-generation-reconnect-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1" ], "testFiles": [ "src/main/runtime/orca-runtime.test.ts", + "src/main/runtime/remote-runtime-request-connection.integration.test.ts", "src/main/runtime/rpc/methods/session-tabs.test.ts", "src/main/runtime/rpc/methods/session-tabs-schemas.test.ts", + "src/main/runtime/rpc/e2ee-channel.test.ts", + "src/main/runtime/rpc/e2ee-channel-v2.test.ts", + "src/main/runtime/rpc/mobile-socket-wiring.test.ts", + "src/main/runtime/rpc/runtime-client-capabilities.test.ts", + "src/shared/remote-runtime-client.test.ts", + "src/shared/remote-runtime-request-connection.test.ts", + "src/shared/remote-runtime-shared-control-connection.test.ts", + "src/cli/runtime/websocket-transport.test.ts", + "src/renderer/src/web/web-runtime-client.test.ts", "src/renderer/src/runtime/web-runtime-session.test.ts", "src/renderer/src/runtime/web-session-close-intent.test.ts", "src/renderer/src/runtime/web-session-tabs-sync.test.ts", "src/renderer/src/components/terminal/terminal-tab-actions.test.ts", "src/renderer/src/components/terminal/terminal-close-incarnation.test.ts", "src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts", - "tests/e2e/daemon-generation-reconnect-safety.spec.ts" + "tests/e2e/daemon-generation-reconnect-safety.spec.ts", + "tests/e2e/daemon-generation-legacy-close-safety.spec.ts" ], "assertionRefs": [ + { + "file": "src/main/runtime/remote-runtime-request-connection.integration.test.ts", + "assertions": [ + "the real encrypted WebSocket handshake binds close-intent capability through authenticated socket state and RPC context to reasonless-close refusal" + ] + }, + { + "file": "tests/e2e/daemon-generation-legacy-close-safety.spec.ts", + "assertions": [ + "one identified capable viewer and one legacy viewer issue byte-identical sequential reasonless closes while a third viewer lists every target before, between, and after but issues zero closes", + "capable v25/v26 PTY root/descendant incarnations in separate worktrees survive, answer post-close input, and produce zero shutdown calls and zero daemon session-killed events", + "legacy v25/v26 PTY root/descendant incarnations die through ordered immediate shutdown calls with one daemon session-killed event each, matching current-main behavior", + "an unaddressed fifth-worktree PTY root and descendant survive with zero kill events, excluding global fanout", + "the JSON reconstruction records request order, negotiated capabilities, viewer connection, worktree/tab/PTY ids, daemon PID/protocol, call site, and exact before/after process liveness" + ] + }, { "file": "tests/e2e/daemon-generation-reconnect-safety.spec.ts", "assertions": [ @@ -1079,12 +1211,71 @@ "shutdown-dispose-failed drops named-pipe authority within the deadline and exact fixture cleanup leaves no process tree" ] }, + { + "file": "src/main/runtime/rpc/mobile-socket-wiring.test.ts", + "assertions": [ + "the optional client capability is captured from legacy encrypted authentication and bound to the authenticated runtime-scoped socket identity" + ] + }, + { + "file": "src/main/runtime/rpc/e2ee-channel.test.ts", + "assertions": [ + "runtime capabilities are accepted only from encrypted authentication metadata, not the unauthenticated hello" + ] + }, + { + "file": "src/main/runtime/rpc/e2ee-channel-v2.test.ts", + "assertions": [ + "mobile E2EE v2 continues to reject additive runtime capability metadata" + ] + }, + { + "file": "src/main/runtime/rpc/runtime-client-capabilities.test.ts", + "assertions": [ + "the authenticated capability parser accepts only bounded string arrays and rejects malformed or oversized input" + ] + }, + { + "file": "src/shared/remote-runtime-client.test.ts", + "assertions": [ + "one-shot and subscription runtime clients remain compatible while sending encrypted client authentication" + ] + }, + { + "file": "src/shared/remote-runtime-request-connection.test.ts", + "assertions": [ + "the cached paired-desktop request connection advertises close-intent support in encrypted authentication while reusing one socket" + ] + }, + { + "file": "src/shared/remote-runtime-shared-control-connection.test.ts", + "assertions": [ + "the reconnecting shared-control client advertises close-intent support in encrypted authentication" + ] + }, + { + "file": "src/cli/runtime/websocket-transport.test.ts", + "assertions": [ + "updated paired runtime clients advertise close-intent support in encrypted auth fields ignored by legacy servers" + ] + }, + { + "file": "src/renderer/src/web/web-runtime-client.test.ts", + "assertions": [ + "the browser paired-runtime client advertises close-intent support inside encrypted authentication" + ] + }, { "file": "src/main/runtime/orca-runtime.test.ts", "assertions": [ "live, unknown, stale, missing-intent, non-owner, and inventory-proven but not yet pane-bound lifecycle closes invoke neither PTY kill nor renderer close", - "dead whole-headless retirement removes stale state without signalling a retained PTY id", - "explicit and authenticated legacy user closes remain destructive" + "dead whole-headless retirement removes stale state without signalling a retained PTY id" + ] + }, + { + "file": "src/main/runtime/rpc/methods/session-tabs.test.ts", + "assertions": [ + "in-process and capable-runtime reasonless closes refuse while legacy runtime/mobile and explicit current user closes retain current-main destructive semantics" ] }, { @@ -1108,6 +1299,15 @@ } ], "evidenceRuns": [ + { + "date": "2026-07-27", + "runner": "ci", + "platform": "linux", + "command": "pnpm exec playwright test tests/e2e/daemon-generation-legacy-close-safety.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", + "result": "passed", + "durationSeconds": 5.1, + "summary": "Current-head capability-gated oracle passed in E2E run https://github.com/stablyai/orca/actions/runs/30250731941/job/89928386794: capable v25/v26 roots and descendants survived with snapshot republish and post-close I/O, byte-identical legacy closes retained current-main shutdown behavior, and the unrelated fifth-worktree control survived." + }, { "date": "2026-07-21", "runner": "local", @@ -1120,7 +1320,7 @@ ], "runtimeBudget": { "p95Seconds": 180, - "scope": "isolated native-Windows five-generation production discovery/reconnect/relaunch plus bounded disposal failure and fresh E2E build" + "scope": "isolated native-Windows generation reconnect plus two-generation mixed-version close adjudication and fresh E2E build" }, "flakeHistory": { "status": "unknown", @@ -1128,11 +1328,11 @@ }, "redGreenEvidence": { "status": "complete", - "evidence": "The pre-fix native run kept daemon/client processes alive but emitted repeated real session-killed events in v21/v22/v23 and terminated every stale-mirror root and descendant. The fixed production-scanner run preserves every exact v21/v22/v23/v24/v25 process incarnation with zero kill events." + "evidence": "Current main and PR #10013 route both reasonless viewer sequences to immediate shutdown, while the prior global-refusal candidate incorrectly preserves the legacy sequence. The capability-gated candidate passed the combined GitHub oracle: capable v25/v26 PTYs survive and answer input, legacy PTYs retain current-main shutdown behavior, and the unrelated control survives. Existing Windows red/green evidence separately covers evidence-bearing lifecycle closes." }, "performanceBudget": { "required": true, - "evidence": "Production adds no polling, subprocess, PowerShell/CIM, or process-per-session work. Concurrent lifecycle closes share one bounded host controller inventory, scan only addressed parent leaves, and reuse the environment/worktree client snapshot deduper. Test-only Windows inventory is capped at 5 seconds and 8 MiB per query; all waits and cleanup are bounded." + "evidence": "Production adds one bounded client-capability parse during the authenticated connection handshake and one constant-time membership branch per close. It adds no polling, subprocess, provider listing, retry, timer, or process-per-session work. Refusal reuses the existing single-worktree snapshot republish. Test-only inventory and cleanup are bounded." }, "promotionCriteria": [ "Collect 100 clean native-Windows runs over 14 days with zero unexplained flakes.", @@ -1140,8 +1340,9 @@ "Add live Linux SSH-relay and Windows WSL reconnect artifacts without weakening keep-on-unknown." ], "knownGaps": [ - "The strongest proof is Electron-as-Node over native Windows, not a packaged UI-driven update journey.", - "Live macOS/Linux adoption and Linux SSH relay are not exercised because Docker is unavailable; Windows WSL reconnect is not exercised because WSL is not installed.", + "The strongest proof is Electron-as-Node over real daemon PTYs, not a packaged headed Orca host paired to a separate old client; that is the primary live E2E still required.", + "Headless orca serve parity, live Linux, Linux SSH relay, and Windows WSL reconnect are not exercised; Docker SSH would cover only the SSH provider and cannot substitute for paired Orca-server evidence.", + "A topology containing any pre-contract paired desktop viewer remains vulnerable to that viewer's stale reasonless close storm; preserving its intentional-close behavior makes this unavoidable until the viewer upgrades.", "Cross-profile daemon inventory and generation handoff/retirement remain the separate #9138/#9229 design.", "A dead split leaf stays with its authoritative owner rather than being remotely pruned." ], @@ -6570,6 +6771,112 @@ "Windows native runtime watches bypass this child path; WSL reservation/release is deterministic-contract tested but not live fault-injected, and SSH registration ownership is not live-relay fault-injected." ], "demotionRule": "Demote or quarantine if the fault harness flakes without a product or harness bug, if healthy operation exceeds one runtime watcher child, if total physical operation exceeds eight children including retiring generations, if quarantine children outlive their roots or repeat after fusing, if event delivery becomes unbounded, or if metadata/stat work returns to the serve process." + }, + { + "id": "terminal-input.plugin-explicit-worktree-routing", + "title": "Plugin terminal input stays inside the freshly resolved worktree", + "maturity": "experimental", + "protection": "partial", + "owner": "plugin-platform", + "layer": "main-relay-contract", + "surfaces": [ + "plugin host API terminal input", + "active worktree resolution", + "provider terminal inventory", + "relay capability enforcement" + ], + "platforms": [ + "macos", + "linux", + "windows" + ], + "providers": [ + "local", + "daemon", + "ssh", + "wsl", + "remote-runtime", + "mobile-relay" + ], + "coveredPlatforms": [ + "macos" + ], + "coveredProviders": [ + "local", + "ssh" + ], + "coverageNotes": "Deterministic macOS contract evidence covers opaque local- and SSH-shaped terminal ids, one bounded worktree listing, mismatch rejection, and the main/relay host-call adapter matrix. It does not launch a live PTY or provision a relay-hosted plugin.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/8549" + ], + "invariant": "terminal.sendText accepts only an explicit provider-owned terminal id present in one bounded inventory of the worktree resolved immediately before the send; an absent id causes zero send calls, and relay callers cannot supply their own capability grants or transport classification.", + "oracle": "Resolve the active worktree once, list that worktree with the v0 terminal cap once, and assert zero sendTerminal calls for a mismatched opaque id versus exactly one send for matching local- and SSH-shaped ids; then run the same permission and schema cases through desktop-main and registered relay panel/worker adapters and compare error codes.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts" + ], + "testFiles": [ + "src/main/plugins/plugin-host-methods.test.ts", + "src/main/plugins/plugin-host-conformance.test.ts" + ], + "assertionRefs": [ + { + "file": "src/main/plugins/plugin-host-methods.test.ts", + "assertions": [ + "a terminal outside the freshly resolved worktree performs one capped list and zero sends", + "matching local- and SSH-shaped opaque ids each perform one capped list and one exact send", + "workspace.readContext drops provider paths, path-bearing internal worktree ids, and terminal titles while capping its terminal projection" + ] + }, + { + "file": "src/main/plugins/plugin-host-conformance.test.ts", + "assertions": [ + "all 13 v0 methods succeed with the required consented capability through desktop-main and relay adapters", + "missing consent, missing capability, unknown method, malformed params, panel-forbidden access, malformed results, and mutation-audit failure return identical codes", + "malformed qualified keys, client-supplied grants, and client-supplied transport flags are rejected before host policy resolution" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-07-10", + "runner": "local", + "platform": "macos", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/main/plugins/plugin-host-methods.test.ts src/main/plugins/plugin-host-conformance.test.ts", + "result": "passed", + "durationSeconds": 0.18, + "summary": "2 files and 17 tests passed, covering the 13-method main/relay conformance matrix and exact terminal routing call counts." + } + ], + "runtimeBudget": { + "p95Seconds": 10, + "scope": "plugin host main/relay contract tests" + }, + "flakeHistory": { + "status": "unknown", + "evidence": "The deterministic focused suite passed locally once and needs CI and soak history before promotion." + }, + "redGreenEvidence": { + "status": "partial", + "evidence": "Exact mismatch/send counts and adapter error parity are asserted; intentional-break and saved CI evidence are still missing." + }, + "performanceBudget": { + "required": true, + "evidence": "Each plugin send resolves once, performs exactly one list capped at 50 terminals, and performs at most one send. The path adds no polling, subprocesses, provider fanout, renderer work, or startup await." + }, + "promotionCriteria": [ + "Run for at least 100 consecutive passes or 14 days across required CI platforms.", + "Attach intentional-break evidence for the worktree membership check and relay transport binding.", + "Exercise live local and SSH provider terminals, including mismatch rejection and successful input echo.", + "Keep relay-hosted plugin provisioning behind a separate reviewed policy before replacing the fail-closed registration." + ], + "knownGaps": [ + "Linux and Windows execution evidence is not recorded.", + "Daemon, WSL, remote-runtime, and mobile-relay providers have no live input evidence.", + "Local and SSH coverage is contract-level over opaque ids, not a live PTY input/echo run.", + "The bounded 50-terminal inventory intentionally rejects a target not present in the capped result; scale behavior above that cap needs a targeted membership API before expansion.", + "Relay-hosted plugin provisioning, consent persistence, workers, and audit services remain out of scope and the relay registration therefore denies every provisioned identity by default." + ], + "demotionRule": "Keep experimental or demote to protection none if the suite flakes, permits a mismatched terminal send, performs more than one inventory list per call, accepts client-supplied grants, or relay and desktop error codes diverge." } ] } diff --git a/config/scripts/build-mac-local.mjs b/config/scripts/build-mac-local.mjs new file mode 100644 index 00000000000..d0426a9468a --- /dev/null +++ b/config/scripts/build-mac-local.mjs @@ -0,0 +1,46 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +export function createLocalBuildVersion(baseVersion, timestamp, commit) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(baseVersion)) { + throw new Error(`Package version is not valid semver: ${baseVersion}`) + } + if (!Number.isSafeInteger(timestamp) || timestamp <= 0) { + throw new Error('Local build timestamp is invalid.') + } + const sanitizedCommit = commit.replace(/[^0-9A-Za-z-]/g, '').slice(0, 12) + if (!sanitizedCommit) { + throw new Error('Git commit identity is empty.') + } + const suffix = `local.${timestamp}.${sanitizedCommit}` + return baseVersion.includes('-') ? `${baseVersion}.${suffix}` : `${baseVersion}-${suffix}` +} + +export function getLocalBuildIdentity() { + const packageJson = JSON.parse(readFileSync(resolve('package.json'), 'utf8')) + const commit = execFileSync('git', ['rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8' + }).trim() + return { + commit, + version: createLocalBuildVersion(packageJson.version, Date.now(), commit) + } +} + +if (process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename)) { + const identity = getLocalBuildIdentity() + console.log(`[build:mac] local update version ${identity.version}`) + execFileSync( + process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm', + ['exec', 'electron-builder', '--config', 'config/electron-builder.config.cjs', '--mac'], + { + env: { + ...process.env, + ORCA_BUILD_COMMIT: identity.commit, + ORCA_LOCAL_BUILD_VERSION: identity.version + }, + stdio: 'inherit' + } + ) +} diff --git a/config/scripts/build-mac-local.test.mjs b/config/scripts/build-mac-local.test.mjs new file mode 100644 index 00000000000..21c5874323d --- /dev/null +++ b/config/scripts/build-mac-local.test.mjs @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { createLocalBuildVersion } from './build-mac-local.mjs' + +describe('createLocalBuildVersion', () => { + it('creates unique valid prerelease versions without changing the release base', () => { + expect(createLocalBuildVersion('1.4.159-rc.0', 123456, 'abc123')).toBe( + '1.4.159-rc.0.local.123456.abc123' + ) + expect(createLocalBuildVersion('1.4.159', 123456, 'abc123')).toBe('1.4.159-local.123456.abc123') + }) + + it('sanitizes commit identifiers', () => { + expect(createLocalBuildVersion('1.0.0', 1, 'abc/def')).toBe('1.0.0-local.1.abcdef') + }) +}) diff --git a/config/scripts/check-quadratic-buffer-concat.mjs b/config/scripts/check-quadratic-buffer-concat.mjs new file mode 100644 index 00000000000..611157c3eaa --- /dev/null +++ b/config/scripts/check-quadratic-buffer-concat.mjs @@ -0,0 +1,343 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API. +import ts from 'typescript-api' + +const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.mts', '.cts']) +const SKIP_PATH_PARTS = new Set(['node_modules', 'dist', 'out', '.git', '__snapshots__']) +const SCAN_ROOTS = ['src', 'config/scripts', 'tools', 'tests', 'mobile'] + +const LOOP_KINDS = new Set([ + ts.SyntaxKind.ForStatement, + ts.SyntaxKind.ForInStatement, + ts.SyntaxKind.ForOfStatement, + ts.SyntaxKind.WhileStatement, + ts.SyntaxKind.DoStatement +]) + +const ASSIGNMENT_OPERATORS = new Set([ + ts.SyntaxKind.EqualsToken, + ts.SyntaxKind.PlusEqualsToken, + ts.SyntaxKind.QuestionQuestionEqualsToken, + ts.SyntaxKind.BarBarEqualsToken, + ts.SyntaxKind.AmpersandAmpersandEqualsToken +]) + +function normalizeReferenceText(text) { + return text.replaceAll(/\s+/g, '') +} + +// The variable a member/call chain ultimately reads, so `carry.subarray(0, n)` +// still resolves to `carry`. `this.x` stops at the property: the class field is +// the accumulator. +function rootReferenceText(node) { + if (ts.isIdentifier(node)) { + return node.text + } + if (ts.isPropertyAccessExpression(node)) { + return node.expression.kind === ts.SyntaxKind.ThisKeyword + ? normalizeReferenceText(node.getText()) + : rootReferenceText(node.expression) + } + if ( + ts.isElementAccessExpression(node) || + ts.isCallExpression(node) || + ts.isNonNullExpression(node) || + ts.isParenthesizedExpression(node) || + ts.isAsExpression(node) + ) { + return rootReferenceText(node.expression) + } + return undefined +} + +function isBufferConcatCall(node) { + return ( + ts.isCallExpression(node) && + ts.isPropertyAccessExpression(node.expression) && + node.expression.name.text === 'concat' && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'Buffer' && + node.arguments.length > 0 && + ts.isArrayLiteralExpression(node.arguments[0]) + ) +} + +function enclosingLoop(node) { + for (let current = node.parent; current; current = current.parent) { + if (LOOP_KINDS.has(current.kind)) { + return current + } + } + return undefined +} + +// A `for (let acc = ...; ;)` binding is loop-carried even though it sits inside +// the loop node, so the initializer does not count as loop-local. +function isDeclaredInsideLoop(declarationStart, loop) { + if (declarationStart < loop.getStart() || declarationStart >= loop.end) { + return false + } + const initializer = ts.isForStatement(loop) ? loop.initializer : undefined + if (!initializer) { + return true + } + return declarationStart < initializer.getStart() || declarationStart >= initializer.end +} + +function collectBindingNames(name, into) { + if (ts.isIdentifier(name)) { + into.push(name) + return + } + if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + for (const element of name.elements) { + if (ts.isBindingElement(element)) { + collectBindingNames(element.name, into) + } + } + } +} + +function collectDeclarations(sourceFile) { + const declarations = new Map() + const record = (identifier, node) => { + const existing = declarations.get(identifier.text) + const start = node.getStart(sourceFile) + if (existing) { + existing.push(start) + } else { + declarations.set(identifier.text, [start]) + } + } + + const visit = (node) => { + if (ts.isVariableDeclaration(node) || ts.isParameter(node)) { + const names = [] + collectBindingNames(node.name, names) + for (const identifier of names) { + record(identifier, node) + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return declarations +} + +function collectAssignedRoots(loop) { + const assigned = new Set() + const visit = (node) => { + if (ts.isBinaryExpression(node) && ASSIGNMENT_OPERATORS.has(node.operatorToken.kind)) { + const root = rootReferenceText(node.left) + if (root) { + assigned.add(root) + } + } + ts.forEachChild(node, visit) + } + visit(loop.statement) + return assigned +} + +// Unwrap the wrappers a concat result passes through before it lands on the +// left-hand side, so `acc = cond ? Buffer.concat([acc, c]) : c` still counts. +function assignmentTargetOf(call) { + let node = call + let parent = node.parent + while ( + parent && + (ts.isParenthesizedExpression(parent) || + ts.isAsExpression(parent) || + ts.isNonNullExpression(parent) || + (ts.isConditionalExpression(parent) && parent.condition !== node)) + ) { + node = parent + parent = parent.parent + } + if ( + parent && + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.EqualsToken && + parent.right === node + ) { + return { + text: normalizeReferenceText(parent.left.getText()), + root: rootReferenceText(parent.left) + } + } + return undefined +} + +function concatOperands(call) { + return call.arguments[0].elements.map((element) => { + const spread = ts.isSpreadElement(element) + const expression = spread ? element.expression : element + return { + spread, + text: normalizeReferenceText(expression.getText()), + root: rootReferenceText(expression) + } + }) +} + +// A binding reset on every iteration cannot accumulate; only one that outlives +// the iteration carries the cost forward. +function isLoopCarried(root, loop, declarations) { + const starts = declarations.get(root) + if (!starts) { + return true + } + return !starts.some((start) => isDeclaredInsideLoop(start, loop)) +} + +function quadraticAccumulator(call, loop, declarations, assignedRoots) { + const operands = concatOperands(call) + const target = assignmentTargetOf(call) + // The result feeds straight back into its own input: every iteration re-copies + // everything accumulated so far. + const selfOperand = target + ? operands.find((operand) => operand.text === target.text || operand.root === target.root) + : undefined + if (selfOperand && target.root && isLoopCarried(target.root, loop, declarations)) { + return target.text + } + + // No direct self-assignment, so look for a loop-carried operand reassigned + // inside the loop: the concat result reaches it by some other path. Spread + // operands are the sanctioned chunk-list fix, not this bug, so skip them. + for (const operand of operands) { + if (operand.spread || !operand.root || !assignedRoots.has(operand.root)) { + continue + } + if (isLoopCarried(operand.root, loop, declarations)) { + return operand.root + } + } + return undefined +} + +export function reportQuadraticBufferConcat(filePath, sourceText) { + if (!sourceText.includes('Buffer.concat')) { + return [] + } + const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true) + const declarations = collectDeclarations(sourceFile) + const assignedRootsByLoop = new Map() + const reports = [] + const seen = new Set() + + const visit = (node) => { + if (isBufferConcatCall(node)) { + const loop = enclosingLoop(node) + if (loop) { + let assignedRoots = assignedRootsByLoop.get(loop) + if (!assignedRoots) { + assignedRoots = collectAssignedRoots(loop) + assignedRootsByLoop.set(loop, assignedRoots) + } + const accumulator = quadraticAccumulator(node, loop, declarations, assignedRoots) + if (accumulator) { + const start = node.getStart(sourceFile) + if (!seen.has(start)) { + seen.add(start) + const { line, character } = sourceFile.getLineAndCharacterOfPosition(start) + reports.push({ + filePath, + line: line + 1, + column: character + 1, + accumulator, + text: node.getText() + }) + } + } + } + } + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return reports +} + +export function normalizePath(root, filePath) { + return path.relative(root, filePath).split(path.sep).join('/') +} + +function isSkippedFile(root, filePath) { + const relative = normalizePath(root, filePath) + // Benchmarks keep the pre-fix shape on purpose so they can measure against it. + if ( + relative.includes('.test.') || + relative.includes('.spec.') || + relative.includes('-benchmark.') + ) { + return true + } + return relative.split('/').some((part) => SKIP_PATH_PARTS.has(part)) +} + +async function collectSourceFiles(root, dir) { + let entries + try { + entries = await fs.readdir(dir, { withFileTypes: true }) + } catch { + return [] + } + const files = [] + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (!SKIP_PATH_PARTS.has(entry.name)) { + files.push(...(await collectSourceFiles(root, fullPath))) + } + } else if ( + entry.isFile() && + SOURCE_EXTENSIONS.has(path.extname(entry.name)) && + !isSkippedFile(root, fullPath) + ) { + files.push(fullPath) + } + } + + return files +} + +function formatReports(root, reports) { + return reports + .map( + (report) => + `${normalizePath(root, report.filePath)}:${report.line}:${report.column} ${report.accumulator} — ${report.text.replaceAll(/\s+/g, ' ')}` + ) + .join('\n') +} + +export async function main(root = process.cwd()) { + const reports = [] + for (const scanRoot of SCAN_ROOTS) { + const files = await collectSourceFiles(root, path.join(root, scanRoot)) + for (const filePath of files) { + const sourceText = await fs.readFile(filePath, 'utf8') + reports.push(...reportQuadraticBufferConcat(filePath, sourceText)) + } + } + if (reports.length === 0) { + return 0 + } + + console.error('Buffer.concat must not rebuild a loop-carried accumulator.') + console.error('Each iteration re-copies everything accumulated so far, so the loop is O(n^2).') + console.error('Collect the pieces in a Buffer[] and Buffer.concat(chunks) once, after the loop.') + console.error('') + console.error(formatReports(root, reports)) + return 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + process.exit(await main()) +} diff --git a/config/scripts/check-quadratic-buffer-concat.test.mjs b/config/scripts/check-quadratic-buffer-concat.test.mjs new file mode 100644 index 00000000000..bc17ce8c7d3 --- /dev/null +++ b/config/scripts/check-quadratic-buffer-concat.test.mjs @@ -0,0 +1,146 @@ +import { describe, expect, it } from 'vitest' + +import { reportQuadraticBufferConcat } from './check-quadratic-buffer-concat.mjs' + +describe('check-quadratic-buffer-concat', () => { + it('reports an accumulator rebuilt from itself in a for-of loop', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([acc, chunk]) }' + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('acc') + }) + + it('reports the accumulator when it trails the new chunk', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([chunk, acc]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('reports a spread of the accumulator into its own concat', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); for (const chunk of chunks) { acc = Buffer.concat([...acc, chunk]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('reports the real pre-fix ai-vault stream carry', () => { + const reports = reportQuadraticBufferConcat( + 'session-scanner-parse-cache.ts', + `async function read(stream) { + let remainder = null + for await (const chunk of stream) { + const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + remainder = Buffer.from(data.subarray(lineStart)) + } + }` + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('remainder') + }) + + it('reports the real pre-fix transcript reader carry, which never names itself on the left', () => { + const reports = reportQuadraticBufferConcat( + 'agent-hook-listener.ts', + `function read(fd, size) { + let carryBytes = Buffer.alloc(0) + while (bytesRead < size) { + const combined = Buffer.concat([buffer.subarray(0, n), carryBytes]) + carryBytes = combined.subarray(0, firstNewline) + } + }` + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('carryBytes') + }) + + it('accepts the chunk-list fix that joins once after the loop', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'const parts = []; for (const chunk of chunks) { parts.push(chunk) } const out = Buffer.concat(parts)' + ) + + expect(reports).toHaveLength(0) + }) + + it('accepts spreading a carry chunk list, which is the sanctioned fix', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + `let carryChunks = [] + while (scanEnd > 0) { + const region = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + }` + ) + + expect(reports).toHaveLength(0) + }) + + it('accepts a concat whose result never outlives the iteration', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'for (const group of groups) { const frame = Buffer.concat([group.header, group.body]); send(frame) }' + ) + + expect(reports).toHaveLength(0) + }) + + it('accepts a loop-local buffer declared and rebuilt inside the same iteration', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'for (const chunk of chunks) { let framed = HEADER; framed = Buffer.concat([framed, chunk]); send(framed) }' + ) + + expect(reports).toHaveLength(0) + }) + + it('reports an accumulator declared in a classic for initializer', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'for (let acc = Buffer.alloc(0), i = 0; i < n; i++) { acc = Buffer.concat([acc, chunks[i]]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('reports a class field accumulator rebuilt inside a loop', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'class Reader { read() { while (this.open) { this.pending = Buffer.concat([this.pending, chunk]) } } }' + ) + + expect(reports).toHaveLength(1) + expect(reports[0].accumulator).toBe('this.pending') + }) + + it('reports an accumulator guarded by an emptiness ternary', () => { + const reports = reportQuadraticBufferConcat( + 'Example.ts', + 'let acc = Buffer.alloc(0); while (open) { acc = acc.length === 0 ? chunk : Buffer.concat([acc, chunk]) }' + ) + + expect(reports).toHaveLength(1) + }) + + it('ignores a per-event accumulator with no enclosing loop', () => { + const reports = reportQuadraticBufferConcat( + 'scrcpy-stream-session.ts', + 'class S { handleVideoChunk(chunk) { let buffer = Buffer.concat([this.pendingVideo, chunk]); this.pendingVideo = parse(buffer).pending } }' + ) + + expect(reports).toHaveLength(0) + }) + + it('ignores files with no Buffer.concat at all', () => { + expect(reportQuadraticBufferConcat('Example.ts', 'export const x = 1')).toHaveLength(0) + }) +}) diff --git a/config/scripts/claude-usage-yield-benchmark.mjs b/config/scripts/claude-usage-yield-benchmark.mjs new file mode 100644 index 00000000000..c587476268e --- /dev/null +++ b/config/scripts/claude-usage-yield-benchmark.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Benchmark: the event-loop yield in the Claude usage scanner's batch loops. +// +// scanner.ts yielded with `setTimeout(resolve, 0)`, which Node clamps to ~1ms. The +// loops yield once per FILE_SCAN_BATCH_SIZE files across two passes, so a machine with +// thousands of transcripts spent seconds parked on timers doing no work. setImmediate +// yields on the same tick's check phase with no clamp. The sibling scanner +// (src/main/codex-usage/scanner.ts) already used setImmediate. +// +// The yield exists to keep the main process responsive during a scan, so this also +// measures worst-case latency for a concurrent task -- a "faster" yield that starved +// other work would be a regression, not a win. +// +// Run with: node config/scripts/claude-usage-yield-benchmark.mjs +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { readdirSync, statSync } from 'node:fs' + +const REPO_ROOT = new URL('../..', import.meta.url) +const ROUNDS = Number(process.env.ORCA_YIELD_BENCH_ROUNDS ?? '10') + +// Why re-read the source: the claim is that the scanner yields once per batch across +// two loops. If the batch size or the yield sites change, these numbers stop meaning +// what the header says, so fail loudly instead of reporting a stale ratio. +const SCANNER_SOURCE = readFileSync(new URL('src/main/claude-usage/scanner.ts', REPO_ROOT), 'utf8') +const batchMatch = SCANNER_SOURCE.match(/const FILE_SCAN_BATCH_SIZE = (\d+)/) +if (!batchMatch) { + throw new Error('FILE_SCAN_BATCH_SIZE not found; this benchmark is stale') +} +const FILE_SCAN_BATCH_SIZE = Number(batchMatch[1]) +const YIELD_SITES = (SCANNER_SOURCE.match(/await yieldToEventLoop\(\)/g) ?? []).length +if (YIELD_SITES === 0) { + throw new Error('no yieldToEventLoop call sites found; this benchmark is stale') +} +// Match the call, not the word: a comment mentioning setImmediate would satisfy a +// bare substring check even after the yield reverted to setTimeout. +if (!/setImmediate\(resolve\)/.test(SCANNER_SOURCE)) { + throw new Error('scanner no longer yields with setImmediate; this benchmark is stale') +} + +// Real transcript count drives the yield count, so read it rather than assume one. +function countClaudeTranscripts() { + const root = join(homedir(), '.claude', 'projects') + let count = 0 + const stack = [root] + while (stack.length > 0) { + const dir = stack.pop() + let entries + try { + entries = readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isDirectory()) { + stack.push(join(dir, entry.name)) + } else if (entry.name.endsWith('.jsonl')) { + count += 1 + } + } + } + return count +} + +const transcriptCount = (() => { + try { + statSync(join(homedir(), '.claude', 'projects')) + return countClaudeTranscripts() + } catch { + return 0 + } +})() + +const FALLBACK_TRANSCRIPTS = 7500 +const effectiveTranscripts = transcriptCount > 0 ? transcriptCount : FALLBACK_TRANSCRIPTS +// Each pass walks ceil(files / batch) batches and yields after every batch except the +// last, so a pass yields batchesPerPass - 1 times. +const BATCHES_PER_PASS = Math.max(1, Math.ceil(effectiveTranscripts / FILE_SCAN_BATCH_SIZE)) +const YIELDS_PER_SCAN = Math.max(1, (BATCHES_PER_PASS - 1) * YIELD_SITES) + +const yieldWithTimeout = () => new Promise((resolve) => setTimeout(resolve, 0)) +const yieldWithImmediate = () => new Promise((resolve) => setImmediate(resolve)) + +// Mirrors the scanner's shape: a little synchronous work per batch, then a yield. +async function runBatchLoop(doYield, batches) { + let sink = 0 + for (let batch = 0; batch < batches; batch += 1) { + for (let file = 0; file < FILE_SCAN_BATCH_SIZE; file += 1) { + sink += (batch * 31 + file) % 7 + } + if (batch + 1 < batches) { + await doYield() + } + } + return sink +} + +async function timeArm(doYield, batches) { + const start = performance.now() + const sink = await runBatchLoop(doYield, batches) + const elapsed = performance.now() - start + if (sink === -1) { + throw new Error('unreachable') + } + return elapsed +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const mid = sorted.length / 2 + return (sorted[mid - 1] + sorted[mid]) / 2 +} + +// Arms alternate which one leads so within-round drift cannot favour either. +async function measure(batches) { + await runBatchLoop(yieldWithTimeout, Math.min(batches, 50)) + await runBatchLoop(yieldWithImmediate, Math.min(batches, 50)) + const timeoutSamples = [] + const immediateSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + if (round % 2 === 0) { + timeoutSamples.push(await timeArm(yieldWithTimeout, batches)) + immediateSamples.push(await timeArm(yieldWithImmediate, batches)) + } else { + immediateSamples.push(await timeArm(yieldWithImmediate, batches)) + timeoutSamples.push(await timeArm(yieldWithTimeout, batches)) + } + } + return { timeoutMs: median(timeoutSamples), immediateMs: median(immediateSamples) } +} + +// The yield exists for responsiveness, so measure what a concurrent task actually sees. +async function measureConcurrentLatency(doYield, batches) { + let worstLatencyMs = 0 + let stop = false + const probe = (async () => { + while (!stop) { + const tick = performance.now() + await new Promise((resolve) => setImmediate(resolve)) + worstLatencyMs = Math.max(worstLatencyMs, performance.now() - tick) + } + })() + const start = performance.now() + await runBatchLoop(doYield, batches) + const scanMs = performance.now() - start + stop = true + await probe + return { scanMs, worstLatencyMs } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Claude usage scanner event-loop yield. Lower is better.') +console.log( + `transcripts=${transcriptCount > 0 ? transcriptCount : `${FALLBACK_TRANSCRIPTS} (none found; synthetic)`} batch=${FILE_SCAN_BATCH_SIZE} sites=${YIELD_SITES} -> ~${YIELDS_PER_SCAN} yields/scan` +) +console.log( + `${pad('yields', 8)} ${pad('setTimeout(0)', 14)} ${pad('setImmediate', 13)} ${pad('speedup', 9)} ${pad('saved', 11)}` +) + +for (const yields of [100, 500, YIELDS_PER_SCAN]) { + // runBatchLoop yields batches - 1 times, so ask for one more batch than yields. + const batches = yields + 1 + const { timeoutMs, immediateMs } = await measure(batches) + // Why report the absolute saving too: the setImmediate arm is small enough that + // background load moves the RATIO a lot while the removed wall time barely budges. + console.log( + `${pad(yields, 8)} ${pad(`${timeoutMs.toFixed(1)} ms`, 14)} ${pad(`${immediateMs.toFixed(1)} ms`, 13)} ${pad(`${(timeoutMs / immediateMs).toFixed(1)}x`, 9)} ${pad(`${(timeoutMs - immediateMs).toFixed(0)} ms`, 11)}` + ) +} + +console.log('\nResponsiveness (the reason the yield exists) at a full scan:') +const timeoutLatency = await measureConcurrentLatency(yieldWithTimeout, YIELDS_PER_SCAN + 1) +const immediateLatency = await measureConcurrentLatency(yieldWithImmediate, YIELDS_PER_SCAN + 1) +console.log( + ` setTimeout(0): scan ${timeoutLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${timeoutLatency.worstLatencyMs.toFixed(2)} ms` +) +console.log( + ` setImmediate : scan ${immediateLatency.scanMs.toFixed(0)} ms, worst concurrent wait ${immediateLatency.worstLatencyMs.toFixed(2)} ms` +) +if (immediateLatency.worstLatencyMs > timeoutLatency.worstLatencyMs) { + console.log( + '\n NOTE: setImmediate showed a WORSE concurrent wait here. The yield exists for\n responsiveness, so that would be a regression even though the scan is faster.' + ) +} + +console.log( + '\nRead the SAVED column, not the ratio. The setImmediate arm is small enough that\nbackground load swings the ratio (32x-81x observed across runs on a loaded machine)\nwhile the removed wall time stays at ~4.2-5.1 s. The saving is wall-clock the main\nprocess spent parked on timer clamps, not CPU work removed. It is paid on every\nUsage-pane scan and every forced automation rescan.' +) diff --git a/config/scripts/command-code-transcript-scan-benchmark.mjs b/config/scripts/command-code-transcript-scan-benchmark.mjs new file mode 100644 index 00000000000..02ea0d09e78 --- /dev/null +++ b/config/scripts/command-code-transcript-scan-benchmark.mjs @@ -0,0 +1,427 @@ +#!/usr/bin/env node +// Benchmark: cost of resolving a Command Code turn prompt from the transcript, +// paid on EVERY command-code hook event (PreToolUse/PostToolUse fire once per +// tool call, so many per second during an active agent turn). +// +// Before the fix, readLastCommandCodeUserPromptEntryFromTranscript() read up to +// TRANSCRIPT_MAX_SCAN_BYTES (4 MB) synchronously, decoded it all to a JS string, +// and JSON-parsed EVERY line to the end of the buffer to find the LAST user +// entry — so cost grew with the transcript, which only grows as a session runs. +// +// The fix scans backward from EOF in TRANSCRIPT_CHUNK_BYTES blocks and returns +// on the first user line, the shape the sibling readLastTextFromTranscriptOnce +// already used. The answer sits near EOF in a real session (the current turn's +// prompt precedes only this turn's output), so the scan reads one or two blocks +// instead of the whole file. +// +// Both implementations are mirrored here: node cannot import the .ts source, +// matching the other benchmarks in this directory. Constants are re-read from +// the real module so a drifted cap fails loudly instead of measuring dead code. +import { + closeSync, + mkdtempSync, + openSync, + readFileSync, + readSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const LISTENER_SOURCE = readFileSync( + fileURLToPath(new URL('../../src/shared/agent-hook-listener.ts', import.meta.url)), + 'utf8' +) + +function readMirroredConstant(name) { + const match = LISTENER_SOURCE.match(new RegExp(`const ${name} = ([^\\n]+)`)) + if (!match) { + throw new Error(`agent-hook-listener.ts no longer defines ${name}; re-sync this benchmark.`) + } + const value = Number(new Function(`return (${match[1]})`)()) + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} did not resolve to a positive integer`) + } + return value +} + +const TRANSCRIPT_CHUNK_BYTES = readMirroredConstant('TRANSCRIPT_CHUNK_BYTES') +const TRANSCRIPT_MAX_SCAN_BYTES = readMirroredConstant('TRANSCRIPT_MAX_SCAN_BYTES') +const EMPTY_REGION = Buffer.alloc(0) +const ITERATIONS = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_ITERATIONS ?? '150', 10) +const WARMUP = Number.parseInt(process.env.ORCA_CC_SCAN_BENCH_WARMUP ?? '20', 10) + +for (const [name, value] of [ + ['ORCA_CC_SCAN_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_CC_SCAN_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +// Mirror of parseAgentHookJson: the real reader scans a line's structure before +// parsing it, on BOTH sides of this comparison. Omitting it made the pre-fix +// column ~9x too fast and invented a regression that does not exist. +const HOOK_STRUCTURAL_TOKENS = 128 * 1024 +const HOOK_NESTING_DEPTH = 64 + +function assertJsonStructure(content) { + let structuralTokens = 0 + let depth = 0 + let inString = false + let escaped = false + for (let index = 0; index < content.length; index += 1) { + const character = content[index] + if (inString) { + if (escaped) { + escaped = false + } else if (character === '\\') { + escaped = true + } else if (character === '"') { + inString = false + } + continue + } + if (character === '"') { + inString = true + continue + } + if ( + character !== '{' && + character !== '}' && + character !== '[' && + character !== ']' && + character !== ',' && + character !== ':' + ) { + continue + } + structuralTokens += 1 + if (structuralTokens > HOOK_STRUCTURAL_TOKENS) { + throw new Error('structuralTokens') + } + if (character === '{' || character === '[') { + depth += 1 + if (depth > HOOK_NESTING_DEPTH) { + throw new Error('nestingDepth') + } + } else if (character === '}' || character === ']') { + depth = Math.max(0, depth - 1) + } + } +} + +function extractUserPrompt(line) { + let entry + try { + assertJsonStructure(line) + entry = JSON.parse(line) + } catch { + return undefined + } + if (typeof entry !== 'object' || entry === null || entry.role !== 'user') { + return undefined + } + const content = entry.content + if (typeof content === 'string' && content.trim().length > 0) { + return content + } + if (Array.isArray(content)) { + for (const part of content) { + if (typeof part === 'object' && part !== null) { + const text = part.text + if (typeof text === 'string' && text.trim().length > 0) { + return text + } + } + } + } + return undefined +} + +// Pre-fix: read the capped window, then parse every line to the end. +function readForward(path) { + const size = statSync(path).size + if (size <= 0) { + return undefined + } + const bytesToRead = Math.min(size, TRANSCRIPT_MAX_SCAN_BYTES) + const position = size - bytesToRead + const fd = openSync(path, 'r') + try { + const buffer = Buffer.alloc(bytesToRead) + let filled = 0 + while (filled < bytesToRead) { + const n = readSync(fd, buffer, filled, bytesToRead - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + let text = buffer.subarray(0, filled).toString('utf8') + if (position > 0) { + const firstNewline = text.indexOf('\n') + text = firstNewline === -1 ? '' : text.slice(firstNewline + 1) + } + let last + for (const line of text.split('\n')) { + const prompt = extractUserPrompt(line.trim()) + if (prompt !== undefined) { + last = prompt + } + } + return last + } finally { + closeSync(fd) + } +} + +function findLastPromptInRegion(region) { + let lineEnd = region.length + for (let index = region.length - 1; index >= -1; index--) { + if (index >= 0 && region[index] !== 0x0a) { + continue + } + const lineStart = index + 1 + if (lineEnd > lineStart) { + const prompt = extractUserPrompt(region.subarray(lineStart, lineEnd).toString('utf8').trim()) + if (prompt !== undefined) { + return prompt + } + } + lineEnd = index + } + return undefined +} + +// Post-fix: walk backward from EOF, return on the first user line. The carry is +// a chunk list, not a re-joined buffer, so one oversized line stays linear. +function readBackward(path) { + const size = statSync(path).size + if (size <= 0) { + return undefined + } + const fd = openSync(path, 'r') + try { + let carryChunks = [] + let bytesRead = 0 + let scanEnd = size + while (scanEnd > 0 && bytesRead < TRANSCRIPT_MAX_SCAN_BYTES) { + const chunkSize = Math.min( + scanEnd, + TRANSCRIPT_CHUNK_BYTES, + TRANSCRIPT_MAX_SCAN_BYTES - bytesRead + ) + const position = scanEnd - chunkSize + const buffer = Buffer.alloc(chunkSize) + let filled = 0 + while (filled < chunkSize) { + const n = readSync(fd, buffer, filled, chunkSize - filled, position + filled) + if (n === 0) { + break + } + filled += n + } + if (filled < chunkSize) { + break + } + bytesRead += filled + scanEnd = position + const firstNewline = buffer.indexOf(0x0a) + const atStart = position === 0 + let completeRegion + if (atStart) { + completeRegion = carryChunks.length === 0 ? buffer : Buffer.concat([buffer, ...carryChunks]) + carryChunks = [] + } else if (firstNewline === -1) { + completeRegion = EMPTY_REGION + carryChunks.unshift(buffer) + } else { + const afterNewline = buffer.subarray(firstNewline + 1) + completeRegion = + carryChunks.length === 0 ? afterNewline : Buffer.concat([afterNewline, ...carryChunks]) + carryChunks = [buffer.subarray(0, firstNewline)] + } + if (completeRegion.length > 0) { + const found = findLastPromptInRegion(completeRegion) + if (found !== undefined) { + return found + } + } + } + return undefined + } finally { + closeSync(fd) + } +} + +// A real session: many completed turns, then THIS turn's prompt, then the tool +// output produced since. The prompt therefore sits near EOF. +function writeTranscript(path, priorTurns) { + const lines = [] + for (let index = 0; index < priorTurns; index += 1) { + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] }) + ) + lines.push( + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }] + }) + ) + } + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }) + ) + for (let index = 0; index < 40; index += 1) { + lines.push( + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }] + }) + ) + } + writeFileSync(path, `${lines.join('\n')}\n`) +} + +// A turn already in progress: `trailingBytes` of tool output sits between the +// prompt and EOF, which is what the backward scan has to read past. +function writeTranscriptWithTrailing(path, priorTurns, trailingBytes) { + const lines = [] + for (let index = 0; index < priorTurns; index += 1) { + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: `older turn ${index}` }] }) + ) + lines.push( + JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'assistant output '.repeat(30)}${index}` }] + }) + ) + } + lines.push( + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }) + ) + let written = 0 + let index = 0 + while (written < trailingBytes) { + const line = JSON.stringify({ + role: 'assistant', + content: [{ type: 'text', text: `${'current turn output '.repeat(30)}${index}` }] + }) + lines.push(line) + written += line.length + 1 + index += 1 + } + writeFileSync(path, `${lines.join('\n')}\n`) +} + +// One tool result larger than many read blocks — the shape with no newline for +// the backward scan to stop on. +function writeTranscriptWithHugeLine(path, lineBytes) { + const lines = [ + JSON.stringify({ role: 'user', content: [{ type: 'text', text: 'the current prompt' }] }), + JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'x'.repeat(lineBytes) }] }) + ] + writeFileSync(path, `${lines.join('\n')}\n`) +} + +function measure(fn, path) { + for (let index = 0; index < WARMUP; index += 1) { + fn(path) + } + const samples = [] + for (let round = 0; round < 3; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + fn(path) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[1] +} + +const dir = mkdtempSync(join(tmpdir(), 'orca-cc-transcript-bench-')) +try { + const rows = [] + for (const priorTurns of [250, 1000, 3000, 6000]) { + const path = join(dir, `transcript-${priorTurns}.jsonl`) + writeTranscript(path, priorTurns) + const forward = readForward(path) + const backward = readBackward(path) + if (forward !== backward) { + throw new Error(`prompt mismatch at ${priorTurns} prior turns: ${forward} vs ${backward}`) + } + if (backward !== 'the current prompt') { + throw new Error(`benchmark fixture resolved the wrong prompt: ${backward}`) + } + rows.push({ + sizeMb: statSync(path).size / (1024 * 1024), + beforeMs: measure(readForward, path), + afterMs: measure(readBackward, path) + }) + } + + const pad = (value, width) => String(value).padStart(width) + console.log('Command Code transcript prompt read, per hook event') + console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 3 rounds)`) + console.log( + `${pad('size', 9)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('speedup', 9)}` + ) + for (const row of rows) { + console.log( + `${pad(`${row.sizeMb.toFixed(2)} MB`, 9)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(0)}x`, 9)}` + ) + } + console.log( + '\nThe old cost grows with the transcript; the new cost is flat because the\ncurrent turn’s prompt sits near EOF and the scan stops at the first hit.' + ) + + // Worst cases, reported even where the ratio is below 1x. The new cost scales + // with bytes-AFTER the prompt, so a long turn (many tool calls since the ask) + // and a single oversized tool result are where the win decays or inverts. + const worst = [] + for (const trailingKb of [32, 256, 1024, 3072]) { + const path = join(dir, `trailing-${trailingKb}.jsonl`) + writeTranscriptWithTrailing(path, 1500, trailingKb * 1024) + if (readForward(path) !== readBackward(path)) { + throw new Error(`prompt mismatch at trailing ${trailingKb} KB`) + } + worst.push({ + label: `${(trailingKb / 1024).toFixed(2)} MB after prompt`, + beforeMs: measure(readForward, path), + afterMs: measure(readBackward, path) + }) + } + const hugePath = join(dir, 'huge-line.jsonl') + writeTranscriptWithHugeLine(hugePath, 3 * 1024 * 1024) + if (readForward(hugePath) !== readBackward(hugePath)) { + throw new Error('prompt mismatch on the oversized-line fixture') + } + worst.push({ + label: '3 MB single line', + beforeMs: measure(readForward, hugePath), + afterMs: measure(readBackward, hugePath) + }) + + console.log('\nWorst cases (win decays as a turn progresses; <1x means slower):') + console.log( + `${pad('case', 22)} ${pad('before ms', 11)} ${pad('after ms', 10)} ${pad('ratio', 9)}` + ) + for (const row of worst) { + console.log( + `${pad(row.label, 22)} ${pad(row.beforeMs.toFixed(3), 11)} ${pad(row.afterMs.toFixed(3), 10)} ${pad(`${(row.beforeMs / row.afterMs).toFixed(2)}x`, 9)}` + ) + } + console.log( + '\nThe win shrinks toward parity as output accumulates after the prompt, since\nthe backward scan has to read past all of it. The single-line row is the floor:\nno newline to stop on, so the scan reads the line in blocks and joins once where\nthe old code issued one flat read. Both sides pay the same per-line structure\nscan, and the carry is a chunk list, so cost stays linear either way.' + ) +} finally { + rmSync(dir, { recursive: true, force: true }) +} diff --git a/config/scripts/dev-cli-terminal-wrapper.mjs b/config/scripts/dev-cli-terminal-wrapper.mjs new file mode 100644 index 00000000000..6563eb991b7 --- /dev/null +++ b/config/scripts/dev-cli-terminal-wrapper.mjs @@ -0,0 +1,40 @@ +import { chmodSync, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +function escapeWindowsBatchValue(value) { + // Why: cmd.exe expands %NAME% even inside quotes, so literal path percent signs must be doubled. + return value.replaceAll('%', '%%') +} + +export function prepareDevCliTerminalWrappers({ + repoRoot, + userDataPath, + electronExecutable, + platform = process.platform +}) { + const binDir = path.join(repoRoot, 'out', 'bin') + const userDataBinDir = path.join(userDataPath, 'cli', 'bin') + const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js') + mkdirSync(binDir, { recursive: true }) + mkdirSync(userDataBinDir, { recursive: true }) + + if (platform === 'win32') { + const wrapperContent = `@echo off\r\nset "ORCA_USER_DATA_PATH=${escapeWindowsBatchValue(userDataPath)}"\r\nset "ORCA_DEV_CLI_INVOCATION=1"\r\nset "ORCA_APP_EXECUTABLE=${escapeWindowsBatchValue(electronExecutable)}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${escapeWindowsBatchValue(cliPath)}" %*\r\n` + for (const targetDir of [binDir, userDataBinDir]) { + for (const commandName of ['orca-dev.cmd', 'orca.cmd']) { + writeFileSync(path.join(targetDir, commandName), wrapperContent, 'utf8') + } + } + } else { + const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_DEV_CLI_INVOCATION=1\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronExecutable)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n` + for (const targetDir of [binDir, userDataBinDir]) { + for (const commandName of ['orca-dev', 'orca']) { + const wrapperPath = path.join(targetDir, commandName) + writeFileSync(wrapperPath, wrapperContent, 'utf8') + chmodSync(wrapperPath, 0o755) + } + } + } + + return { binDir, userDataBinDir } +} diff --git a/config/scripts/dev-cli-terminal-wrapper.test.mjs b/config/scripts/dev-cli-terminal-wrapper.test.mjs new file mode 100644 index 00000000000..c5bc5f4cdd1 --- /dev/null +++ b/config/scripts/dev-cli-terminal-wrapper.test.mjs @@ -0,0 +1,70 @@ +import { mkdtempSync, readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs' + +describe('dev CLI terminal wrappers', () => { + it('writes profile-scoped Windows wrappers for worker terminals', () => { + const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-')) + const userDataPath = path.join(root, 'profile') + prepareDevCliTerminalWrappers({ + repoRoot: root, + userDataPath, + electronExecutable: path.join(root, 'electron.exe'), + platform: 'win32' + }) + + const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8') + expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath}"`) + expect(wrapper).toContain('set "ORCA_DEV_CLI_INVOCATION=1"') + expect(wrapper).toContain(`node "${path.join(root, 'out', 'cli', 'index.js')}" %*`) + expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper) + expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper) + expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper) + }) + + it('escapes literal percent signs in every Windows batch path', () => { + const root = path.join(mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-')), '%repo%') + const userDataPath = path.join(root, '%profile%') + const electronExecutable = path.join(root, '%electron%', 'electron.exe') + prepareDevCliTerminalWrappers({ + repoRoot: root, + userDataPath, + electronExecutable, + platform: 'win32' + }) + + const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev.cmd'), 'utf8') + expect(wrapper).toContain(`set "ORCA_USER_DATA_PATH=${userDataPath.replaceAll('%', '%%')}"`) + expect(wrapper).toContain( + `set "ORCA_APP_EXECUTABLE=${electronExecutable.replaceAll('%', '%%')}"` + ) + expect(wrapper).toContain( + `node "${path.join(root, 'out', 'cli', 'index.js').replaceAll('%', '%%')}" %*` + ) + expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev.cmd'), 'utf8')).toBe(wrapper) + expect(readFileSync(path.join(root, 'out', 'bin', 'orca.cmd'), 'utf8')).toBe(wrapper) + }) + + it('writes executable-style POSIX wrappers with the same profile identity', () => { + const root = mkdtempSync(path.join(tmpdir(), 'orca-dev-terminal-wrapper-')) + const userDataPath = path.join(root, 'profile') + prepareDevCliTerminalWrappers({ + repoRoot: root, + userDataPath, + electronExecutable: path.join(root, 'electron'), + platform: 'linux' + }) + + const wrapper = readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca-dev'), 'utf8') + expect(wrapper).toContain(`export ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}`) + expect(wrapper).toContain('export ORCA_DEV_CLI_INVOCATION=1') + expect(wrapper).toContain( + `exec node ${JSON.stringify(path.join(root, 'out', 'cli', 'index.js'))}` + ) + expect(readFileSync(path.join(userDataPath, 'cli', 'bin', 'orca'), 'utf8')).toBe(wrapper) + expect(readFileSync(path.join(root, 'out', 'bin', 'orca-dev'), 'utf8')).toBe(wrapper) + expect(readFileSync(path.join(root, 'out', 'bin', 'orca'), 'utf8')).toBe(wrapper) + }) +}) diff --git a/config/scripts/electron-builder-config.test.mjs b/config/scripts/electron-builder-config.test.mjs index 50104b2cd75..0d4c7dd7180 100644 --- a/config/scripts/electron-builder-config.test.mjs +++ b/config/scripts/electron-builder-config.test.mjs @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -19,6 +19,12 @@ const { } = require('../packaged-runtime-node-modules.cjs') describe('electron-builder config', () => { + it('keeps the packaged app identity aligned with local-build validation', () => { + expect(electronBuilderConfig.appId).toBe( + require('../../src/shared/local-build-compatibility-contract.json').appId + ) + }) + it('excludes repo-only source trees from app.asar', () => { expect(electronBuilderConfig.files).toEqual( expect.arrayContaining([ @@ -35,17 +41,25 @@ describe('electron-builder config', () => { '!pr-evidence{,/**/*}', '!Casks{,/**/*}', '!{AGENTS.md,CLAUDE.md,DEVELOPING.md,bundle-size-progress.md}', - '!out/**/*.test.js' + '!out/**/*.test.js', + '!resources/plugins/launch/**' ]) ) }) it('keeps runtime resources available through extraResources', () => { + const bundledPluginResources = expect.objectContaining({ + from: 'resources/plugins/launch', + to: 'plugins/launch' + }) for (const platform of ['mac', 'linux', 'win']) { expect(electronBuilderConfig[platform].extraResources).toContainEqual({ from: 'resources/skills', to: 'skills' }) + expect(electronBuilderConfig[platform].extraResources).toEqual( + expect.arrayContaining([bundledPluginResources]) + ) } expect(electronBuilderConfig.mac.extraResources).toEqual( expect.arrayContaining([ @@ -165,6 +179,58 @@ describe('electron-builder config', () => { } }) + it('overrides packaged semver only for local macOS builds', () => { + const configPath = require.resolve('../electron-builder.config.cjs') + const original = process.env.ORCA_LOCAL_BUILD_VERSION + const originalMacRelease = process.env.ORCA_MAC_RELEASE + try { + delete require.cache[configPath] + delete process.env.ORCA_MAC_RELEASE + process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-rc.0.local.123.abc' + expect(require('../electron-builder.config.cjs').extraMetadata).toEqual({ + version: '1.4.159-rc.0.local.123.abc' + }) + } finally { + if (originalMacRelease === undefined) { + delete process.env.ORCA_MAC_RELEASE + } else { + process.env.ORCA_MAC_RELEASE = originalMacRelease + } + if (original === undefined) { + delete process.env.ORCA_LOCAL_BUILD_VERSION + } else { + process.env.ORCA_LOCAL_BUILD_VERSION = original + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } + }) + + it('never applies local semver to release packaging', () => { + const configPath = require.resolve('../electron-builder.config.cjs') + const originalLocalVersion = process.env.ORCA_LOCAL_BUILD_VERSION + const originalMacRelease = process.env.ORCA_MAC_RELEASE + try { + delete require.cache[configPath] + process.env.ORCA_LOCAL_BUILD_VERSION = '1.4.159-local.123.abc' + process.env.ORCA_MAC_RELEASE = '1' + expect(require('../electron-builder.config.cjs').extraMetadata).toBeUndefined() + } finally { + if (originalLocalVersion === undefined) { + delete process.env.ORCA_LOCAL_BUILD_VERSION + } else { + process.env.ORCA_LOCAL_BUILD_VERSION = originalLocalVersion + } + if (originalMacRelease === undefined) { + delete process.env.ORCA_MAC_RELEASE + } else { + process.env.ORCA_MAC_RELEASE = originalMacRelease + } + delete require.cache[configPath] + require('../electron-builder.config.cjs') + } + }) + it('uses Orca native rebuild hook instead of electron-builder default rebuild', () => { expect(electronBuilderConfig.beforeBuild).toBe(electronBuilderNativeRebuild) expect(electronBuilderConfig.npmRebuild).toBe(true) @@ -386,6 +452,11 @@ describe('electron-builder config', () => { const resourcesDir = join(root, 'linux-unpacked', 'resources') const launcherPath = join(resourcesDir, 'bin', 'orca-ide') await mkdir(join(resourcesDir, 'bin'), { recursive: true }) + await cp( + join(process.cwd(), 'resources', 'plugins', 'launch'), + join(resourcesDir, 'plugins', 'launch'), + { recursive: true } + ) await mkdir(join(resourcesDir, 'node_modules', 'zod', 'src'), { recursive: true }) // Why: afterPack now fails hard when the unpacked daemon entry is // missing, so the fixture must carry one like a real package layout. diff --git a/config/scripts/electron-builder-native-rebuild.cjs b/config/scripts/electron-builder-native-rebuild.cjs index d4ab82f9430..c33cf3cd651 100644 --- a/config/scripts/electron-builder-native-rebuild.cjs +++ b/config/scripts/electron-builder-native-rebuild.cjs @@ -7,8 +7,8 @@ function electronBuilderNativeRebuild(context) { return runElectronBuilderNativeRebuild(context) } -function runElectronBuilderNativeRebuild(context, runner = execFileSync) { - const args = buildNativeRebuildArgs(context) +function runElectronBuilderNativeRebuild(context, runner = execFileSync, runtime = {}) { + const args = buildNativeRebuildArgs(context, runtime) if (readPlatformName(context?.platform) === 'win32') { runner(process.execPath, ['config/scripts/build-windows-cli-launcher.mjs'], { cwd: projectDir, @@ -25,15 +25,22 @@ function runElectronBuilderNativeRebuild(context, runner = execFileSync) { return false } -function buildNativeRebuildArgs(context) { +function buildNativeRebuildArgs( + context, + { environment = process.env, hostPlatform = process.platform, hostArch = process.arch } = {} +) { const platform = readPlatformName(context?.platform) const arch = readArchName(context?.arch) + const canReusePreparedRuntime = + environment.ORCA_REUSE_PREPARED_NATIVE_RUNTIME === '1' && + platform === hostPlatform && + arch === hostArch return [ 'config/scripts/rebuild-native-deps.mjs', `--platform=${platform}`, `--arch=${arch}`, - '--force' + ...(canReusePreparedRuntime ? [] : ['--force']) ] } diff --git a/config/scripts/electron-builder-native-rebuild.test.mjs b/config/scripts/electron-builder-native-rebuild.test.mjs index 5d382934533..66468c8cf99 100644 --- a/config/scripts/electron-builder-native-rebuild.test.mjs +++ b/config/scripts/electron-builder-native-rebuild.test.mjs @@ -42,6 +42,33 @@ describe('electron-builder native rebuild hook', () => { ]) }) + it('reuses a prepared native runtime only for the host target', () => { + const runtime = { + environment: { ORCA_REUSE_PREPARED_NATIVE_RUNTIME: '1' }, + hostPlatform: 'linux', + hostArch: 'x64' + } + + expect( + buildNativeRebuildArgs( + { + platform: { nodeName: 'linux' }, + arch: 'x64' + }, + runtime + ) + ).toEqual(['config/scripts/rebuild-native-deps.mjs', '--platform=linux', '--arch=x64']) + expect( + buildNativeRebuildArgs( + { + platform: { nodeName: 'linux' }, + arch: 'arm64' + }, + runtime + ) + ).toContain('--force') + }) + it('builds the native CLI launcher before packaging Windows resources', () => { const calls = [] const result = runElectronBuilderNativeRebuild( diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts new file mode 100644 index 00000000000..cc8db3ca741 --- /dev/null +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -0,0 +1,42 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { electronViteConfig } from '../../electron.vite.config' + +const targetConfig = readFileSync('config/electron-vite-target.config.ts', 'utf8') + +describe('Electron Vite output contract', () => { + it('keeps main-process and plain-Node entries at stable CommonJS paths', () => { + const output = electronViteConfig.main?.build?.rollupOptions?.output + if (!output || Array.isArray(output)) { + throw new Error('Expected one main-process output') + } + + expect(output.format).toBe('cjs') + expect(output.entryFileNames).toBe('[name].js') + expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') + }) + + it('externalizes packaged dependencies but bundles the daemon xterm graph', () => { + const external = electronViteConfig.main?.build?.rollupOptions?.external + if (typeof external !== 'function') { + throw new Error('Expected main-process external predicate') + } + + expect(external('node-pty', undefined, false)).toBe(true) + expect(external('@parcel/watcher', undefined, false)).toBe(true) + expect(external('electron', undefined, false)).toBe(true) + expect(external('node:fs', undefined, false)).toBe(true) + expect(external('@xterm/headless', undefined, false)).toBe(false) + expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) + }) + + it('isolates renderer entry side effects behind strict facades', () => { + expect(electronViteConfig.renderer?.build?.rollupOptions?.preserveEntrySignatures).toBe( + 'strict' + ) + }) + + it('rejects prototype properties as build targets', () => { + expect(targetConfig).toContain('Object.prototype.hasOwnProperty.call(configByTarget, target)') + }) +}) diff --git a/config/scripts/git-binary-compatibility-workflow.test.mjs b/config/scripts/git-binary-compatibility-workflow.test.mjs index 67509f7f6cc..56e4fb5c36f 100644 --- a/config/scripts/git-binary-compatibility-workflow.test.mjs +++ b/config/scripts/git-binary-compatibility-workflow.test.mjs @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest' describe('Git binary compatibility PR gate', () => { it('runs the real-binary contract at each compatibility boundary', () => { const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) - const step = workflow.jobs.verify.steps.find( + const step = workflow.jobs.git_compatibility.steps.find( (candidate) => candidate.name === 'Verify Git binary compatibility matrix' ) @@ -16,5 +16,8 @@ describe('Git binary compatibility PR gate', () => { expect(step?.run).toContain('alpine/git:v2.49.1|2.49.1') expect(step?.run).toContain('ORCA_GIT_COMPAT_IMAGE="$image"') expect(step?.run).toContain('src/shared/git-binary-compatibility.test.ts') + expect(step?.run).toContain('-j"$(nproc)"') + expect(step?.run).toContain('pids+=("$!")') + expect(step?.run).toContain('wait "$pid" || status=1') }) }) diff --git a/config/scripts/git-diff-blob-concurrency-benchmark.mjs b/config/scripts/git-diff-blob-concurrency-benchmark.mjs new file mode 100644 index 00000000000..602efd00e1b --- /dev/null +++ b/config/scripts/git-diff-blob-concurrency-benchmark.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +// Benchmark: latency of loading one file diff, which reads two git blobs. +// +// The diff loaders in src/main/git/status.ts awaited their two sides in series, +// so the second `git show` could not start until the first had fully returned. +// The two reads are independent, so that serialization was pure added latency on +// every diff the review panel opens. +// +// This spawns the real `git` binary against this repo, so it measures actual +// process-launch and read cost rather than a model of it. Over SSH each diff is +// one relay RPC and the two spawns run host-local inside the relay, so the same +// relative saving applies to remote-host spawn time, not to network round trips. +import { execFile } from 'node:child_process' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const ITERATIONS = Number(process.env.ORCA_DIFF_BLOB_BENCH_ITERATIONS ?? '10') +const WARMUP = Number(process.env.ORCA_DIFF_BLOB_BENCH_WARMUP ?? '3') + +for (const [name, value] of [ + ['ORCA_DIFF_BLOB_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_DIFF_BLOB_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function git(args) { + return new Promise((resolve, reject) => { + execFile('git', args, { cwd: REPO_ROOT, maxBuffer: 256 * 1024 * 1024 }, (error, stdout) => + error ? reject(error) : resolve(stdout) + ) + }) +} + +// Pre-fix: await one side, then the other. +async function readSequential(leftRef, rightRef, filePath) { + const left = await git(['show', '--end-of-options', `${leftRef}:${filePath}`]) + const right = await git(['show', '--end-of-options', `${rightRef}:${filePath}`]) + return left.length + right.length +} + +// Post-fix: issue both, await together. +async function readConcurrent(leftRef, rightRef, filePath) { + const [left, right] = await Promise.all([ + git(['show', '--end-of-options', `${leftRef}:${filePath}`]), + git(['show', '--end-of-options', `${rightRef}:${filePath}`]) + ]) + return left.length + right.length +} + +// Why interleaved: running one strategy's whole batch before the other's lets +// cache warming, CPU-frequency drift, and background load correlate with the +// strategy being measured. Alternating per iteration and taking medians keeps +// that drift common to both arms. +async function measureInterleaved(leftRef, rightRef, filePath) { + for (let index = 0; index < WARMUP; index += 1) { + await readSequential(leftRef, rightRef, filePath) + await readConcurrent(leftRef, rightRef, filePath) + } + const sequentialSamples = [] + const concurrentSamples = [] + for (let index = 0; index < ITERATIONS; index += 1) { + // Alternate which arm goes first so neither systematically pays a cold cache. + const sequentialFirst = index % 2 === 0 + for (const runSequential of sequentialFirst ? [true, false] : [false, true]) { + const start = performance.now() + await (runSequential ? readSequential : readConcurrent)(leftRef, rightRef, filePath) + ;(runSequential ? sequentialSamples : concurrentSamples).push(performance.now() - start) + } + } + const median = (samples) => { + const sorted = [...samples].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] + } + return { sequential: median(sequentialSamples), concurrent: median(concurrentSamples) } +} + +const head = (await git(['rev-parse', 'HEAD'])).trim() +const parent = `${head}~1` + +// Files that exist on both sides, spanning small to large so the fixed spawn +// cost and the size-dependent read cost are both represented. +const CANDIDATES = [ + 'src/main/git/status.ts', + 'src/shared/agent-hook-listener.ts', + 'src/renderer/src/components/TaskPage.tsx' +] + +const files = [] +for (const filePath of CANDIDATES) { + try { + await git(['cat-file', '-e', `${parent}:${filePath}`]) + await git(['cat-file', '-e', `${head}:${filePath}`]) + files.push(filePath) + } catch { + // Skip a path that does not exist on both sides in this checkout. + } +} +if (files.length === 0) { + throw new Error('no benchmark file exists at both HEAD and HEAD~1 in this checkout') +} + +const pad = (value, width) => String(value).padStart(width) +console.log('One file diff = two git blob reads. Lower is better.') +console.log( + `iterations=${ITERATIONS} warmup=${WARMUP} (interleaved, medians) head=${head.slice(0, 9)}` +) +console.log( + `${pad('file', 26)} ${pad('sequential', 12)} ${pad('concurrent', 12)} ${pad('speedup', 9)} ${pad('saved', 10)}` +) +for (const filePath of files) { + const sequentialBytes = await readSequential(parent, head, filePath) + const concurrentBytes = await readConcurrent(parent, head, filePath) + if (sequentialBytes !== concurrentBytes) { + throw new Error(`byte mismatch for ${filePath}`) + } + const { sequential, concurrent } = await measureInterleaved(parent, head, filePath) + console.log( + `${pad(filePath.split('/').pop(), 26)} ${pad(`${sequential.toFixed(1)} ms`, 12)} ${pad(`${concurrent.toFixed(1)} ms`, 12)} ${pad(`${(sequential / concurrent).toFixed(2)}x`, 9)} ${pad(`${(sequential - concurrent).toFixed(1)} ms`, 10)}` + ) +} +console.log( + '\nThe saving is per diff opened, and is dominated by process launch rather than\nfile size — which is why it holds roughly constant across these files.' +) diff --git a/config/scripts/install-electron-package-binary.mjs b/config/scripts/install-electron-package-binary.mjs index e4fa70a50be..f75d1eae30a 100644 --- a/config/scripts/install-electron-package-binary.mjs +++ b/config/scripts/install-electron-package-binary.mjs @@ -24,6 +24,20 @@ const { downloadArtifact } = electronRequire('@electron/get') const targetPlatform = getElectronTargetPlatform() const targetArch = getElectronTargetArch() const platformPath = getElectronPlatformPath(targetPlatform) +const transientDownloadErrorCodes = new Set([ + 'EAI_AGAIN', + 'ECONNREFUSED', + 'ECONNRESET', + 'ENETDOWN', + 'ENETRESET', + 'ENETUNREACH', + 'ENOTFOUND', + 'EPIPE', + 'ETIMEDOUT', + 'UND_ERR_CONNECT_TIMEOUT', + 'UND_ERR_HEADERS_TIMEOUT', + 'UND_ERR_SOCKET' +]) try { // Why: Electron's own install.js can exit 0 while an async extract promise is @@ -111,7 +125,7 @@ async function installElectronPackageBinary() { const extractDir = join(tempDir, 'extract') try { - const zipPath = await downloadArtifact({ + const downloadOptions = { version: electronVersion, artifactName: 'electron', platform: targetPlatform, @@ -120,7 +134,8 @@ async function installElectronPackageBinary() { force: true, tempDirectory: tempDir, ...(shouldUseRemoteChecksums() ? {} : { checksums: electronRequire('./checksums.json') }) - }) + } + const zipPath = await downloadElectronArtifactWithRetry(downloadOptions) // Why: CI has observed partial extracts directly under node_modules/electron // that leave only dist/locales. Verify in temp before replacing package dist. @@ -145,6 +160,82 @@ async function installElectronPackageBinary() { } } +async function downloadElectronArtifactWithRetry(downloadOptions) { + const retryDelays = getDownloadRetryDelays() + + for (let attempt = 0; ; attempt += 1) { + try { + return await downloadArtifact(downloadOptions) + } catch (error) { + const retryDelay = retryDelays[attempt] + if (retryDelay === undefined || !isTransientDownloadError(error)) { + throw error + } + + console.warn( + `[electron-package] Transient Electron download failure (${formatDownloadError(error)}); ` + + `retrying in ${retryDelay}ms (${attempt + 2}/${retryDelays.length + 1}).` + ) + rmSync(downloadOptions.cacheRoot, { recursive: true, force: true }) + await new Promise((resolveDelay) => setTimeout(resolveDelay, retryDelay)) + } + } +} + +function getDownloadRetryDelays() { + const configured = process.env.ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS + if (!configured) { + return [1_000, 3_000] + } + + const delays = configured.split(',').map(Number) + if (delays.some((delay) => !Number.isSafeInteger(delay) || delay < 0)) { + throw new Error('ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers') + } + return delays +} + +function isTransientDownloadError(error) { + for (const candidate of getErrorChain(error)) { + if (transientDownloadErrorCodes.has(candidate?.code)) { + return true + } + const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode + if ( + statusCode === 408 || + statusCode === 425 || + statusCode === 429 || + (statusCode >= 500 && statusCode < 600) + ) { + return true + } + } + return false +} + +function getErrorChain(error) { + const errors = [] + let candidate = error + while (candidate && errors.length < 5) { + errors.push(candidate) + candidate = candidate.cause + } + return errors +} + +function formatDownloadError(error) { + for (const candidate of getErrorChain(error)) { + const statusCode = candidate?.statusCode ?? candidate?.response?.statusCode + if (statusCode) { + return `HTTP ${statusCode}` + } + if (candidate?.code) { + return candidate.code + } + } + return error instanceof Error ? error.message : String(error) +} + function extractElectronArchive(zipPath, extractDir) { mkdirSync(extractDir, { recursive: true }) // Why: extract-zip/Electron install.js can leave Node 24 with an unsettled diff --git a/config/scripts/install-electron-package-binary.test.mjs b/config/scripts/install-electron-package-binary.test.mjs index aa58dae18a6..2c37f95cfbe 100644 --- a/config/scripts/install-electron-package-binary.test.mjs +++ b/config/scripts/install-electron-package-binary.test.mjs @@ -93,6 +93,102 @@ describe('install-electron-package-binary', () => { } }) + it('retries transient Electron download failures', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { + downloadFailures: 1, + downloadErrorCode: 'ECONNRESET' + }) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0' + }) + + expect(result.status, result.stderr).toBe(0) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(2) + expect(result.stderr).toContain('Transient Electron download failure (ECONNRESET)') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('fails after exhausting transient Electron download retries', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { + downloadFailures: 5, + downloadErrorCode: 'ECONNRESET' + }) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0' + }) + + expect(result.status).toBe(1) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(3) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('rejects invalid Electron download retry delays before downloading', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,nope' + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS must contain non-negative integers' + ) + expect(existsSync(join(projectDir, 'electron-get.log'))).toBe(false) + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + + it('does not retry permanent Electron download failures', () => { + const projectDir = mkTempProject() + + try { + writeFakeElectronPackage(projectDir) + writeFakeElectronGet(projectDir, { + downloadFailures: 3, + downloadErrorCode: 'EACCES' + }) + writeFakeExtractor(projectDir, { createExecutable: true }) + + const result = runInstallScript(projectDir, { + ORCA_ELECTRON_PACKAGE_RETRY_DELAYS_MS: '0,0' + }) + + expect(result.status).toBe(1) + expect( + readFileSync(join(projectDir, 'electron-get.log'), 'utf8').trim().split('\n') + ).toHaveLength(1) + expect(result.stderr).not.toContain('Transient Electron download failure') + } finally { + rmSync(projectDir, { recursive: true, force: true }) + } + }) + it('fails instead of silently accepting a partial Electron extract', () => { const projectDir = mkTempProject() @@ -181,7 +277,10 @@ module.exports = path.join(__dirname, 'dist', fs.readFileSync(pathFile, 'utf8')) ) } -function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) { +function writeFakeElectronGet( + projectDir, + { downloadNeverSettles = false, downloadFailures = 0, downloadErrorCode = 'ECONNRESET' } = {} +) { const getDir = join(projectDir, 'node_modules', 'electron', 'node_modules', '@electron', 'get') mkdirSync(getDir, { recursive: true }) writeFileSync( @@ -189,7 +288,9 @@ function writeFakeElectronGet(projectDir, { downloadNeverSettles = false } = {}) ` const { mkdirSync, writeFileSync, appendFileSync } = require('node:fs') const { join } = require('node:path') +let downloadAttempt = 0 exports.downloadArtifact = async function downloadArtifact(details) { + downloadAttempt += 1 appendFileSync( 'electron-get.log', 'cacheRoot=' + details.cacheRoot + ' platform=' + details.platform + ' arch=' + details.arch + '\\n' @@ -197,6 +298,12 @@ exports.downloadArtifact = async function downloadArtifact(details) { if (${JSON.stringify(downloadNeverSettles)}) { return new Promise(() => {}) } + if (downloadAttempt <= ${JSON.stringify(downloadFailures)}) { + const cause = Object.assign(new Error('download failed'), { + code: ${JSON.stringify(downloadErrorCode)} + }) + throw Object.assign(new TypeError('fetch failed'), { cause }) + } mkdirSync(details.cacheRoot, { recursive: true }) const artifactPath = join(details.cacheRoot, 'electron.zip') writeFileSync(artifactPath, 'fake zip') diff --git a/config/scripts/mac-build-compatibility.cjs b/config/scripts/mac-build-compatibility.cjs new file mode 100644 index 00000000000..4df85d4fea1 --- /dev/null +++ b/config/scripts/mac-build-compatibility.cjs @@ -0,0 +1,34 @@ +const { writeFileSync } = require('node:fs') +const { join } = require('node:path') +const compatibilityContract = require('../../src/shared/local-build-compatibility-contract.json') + +const MAC_BUILD_COMPATIBILITY_FILENAME = 'orca-local-build.json' + +function createMacBuildCompatibility({ version, commit, architecture }) { + if (architecture !== 'arm64' && architecture !== 'x64') { + throw new Error(`Unsupported macOS build architecture: ${architecture}`) + } + return { + ...compatibilityContract, + buildId: `${version}-${commit}-${architecture}`, + version, + commit, + platform: 'darwin', + architecture + } +} + +function writeMacBuildCompatibility(resourcesDir, identity) { + const compatibility = createMacBuildCompatibility(identity) + writeFileSync( + join(resourcesDir, MAC_BUILD_COMPATIBILITY_FILENAME), + `${JSON.stringify(compatibility, null, 2)}\n`, + 'utf8' + ) +} + +module.exports = { + MAC_BUILD_COMPATIBILITY_FILENAME, + createMacBuildCompatibility, + writeMacBuildCompatibility +} diff --git a/config/scripts/mac-build-compatibility.test.mjs b/config/scripts/mac-build-compatibility.test.mjs new file mode 100644 index 00000000000..81b642c503d --- /dev/null +++ b/config/scripts/mac-build-compatibility.test.mjs @@ -0,0 +1,36 @@ +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { createMacBuildCompatibility } = require('./mac-build-compatibility.cjs') + +describe('mac build compatibility metadata', () => { + it('binds version, commit, and architecture into the packaged contract', () => { + expect( + createMacBuildCompatibility({ + version: '1.2.3-local.1', + commit: 'abc123', + architecture: 'arm64' + }) + ).toMatchObject({ + formatVersion: 1, + appId: 'com.stablyai.orca', + buildId: '1.2.3-local.1-abc123-arm64', + version: '1.2.3-local.1', + commit: 'abc123', + stateSchemaVersion: 1, + platform: 'darwin', + architecture: 'arm64' + }) + }) + + it('rejects unsupported architecture metadata', () => { + expect(() => + createMacBuildCompatibility({ + version: '1.2.3', + commit: 'abc123', + architecture: 'universal' + }) + ).toThrow('Unsupported macOS build architecture') + }) +}) diff --git a/config/scripts/mobile-agent-status-projection-benchmark.mjs b/config/scripts/mobile-agent-status-projection-benchmark.mjs new file mode 100644 index 00000000000..8d5d58a2dee --- /dev/null +++ b/config/scripts/mobile-agent-status-projection-benchmark.mjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node +// Benchmark: cost of the mobile agent-status projection per store mutation. +// +// buildRuntimeMobileAgentStatusProjection runs on the App.tsx global store +// subscriber. setAgentStatus replaces one entry and re-spreads +// agentStatusByPaneKey, which defeats the reference-equality skip gate, so before +// the fix EVERY live agent was re-serialized on EVERY status ping — each carrying +// a prompt, a 20-entry stateHistory, toolInput, and an 8 KB-capped +// lastAssistantMessage. +// +// The fix memoizes each row's JSON by entry identity, mirroring the +// cachedTabsProjection pattern already in the same file, so a ping re-serializes +// only the agent that actually changed. +// +// The bucket width is re-read from the real module so a drifted constant fails +// loudly here instead of quietly changing what this benchmark measures. +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const GRAPH_SOURCE = readFileSync( + fileURLToPath(new URL('../../src/renderer/src/runtime/sync-runtime-graph.ts', import.meta.url)), + 'utf8' +) + +const bucketMatch = GRAPH_SOURCE.match(/AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS = ([0-9_]+)/) +if (!bucketMatch) { + throw new Error( + 'sync-runtime-graph.ts no longer defines the updatedAt bucket; re-sync this benchmark.' + ) +} +const BUCKET_MS = Number(bucketMatch[1].replaceAll('_', '')) + +const ITERATIONS = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_ITERATIONS ?? '400', 10) +const WARMUP = Number.parseInt(process.env.ORCA_AGENT_PROJECTION_BENCH_WARMUP ?? '60', 10) + +for (const [name, value] of [ + ['ORCA_AGENT_PROJECTION_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_AGENT_PROJECTION_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function toRow(paneKey, entry) { + return { + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + interrupted: entry.interrupted ?? null + } +} + +function serializeEntry(paneKey, entry) { + return JSON.stringify(toRow(paneKey, entry)) +} + +// Pre-fix: build plain rows and stringify the array once — no per-row roundtrip, +// which the original never paid and which would inflate the reported speedup. +function buildFull(map) { + return JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey, entry]) => toRow(paneKey, entry)) + ) +} + +// Post-fix: reuse each row's JSON while its entry object is unchanged. +function makeCachedBuilder() { + let cache = null + return (map) => { + if (cache?.source === map) { + return cache.projection + } + const previous = cache?.entries + const entries = new Map() + const parts = [] + for (const [paneKey, entry] of Object.entries(map).sort(([a], [b]) => a.localeCompare(b))) { + const prior = previous?.get(paneKey) + const row = + prior?.entry === entry ? prior : { entry, projection: serializeEntry(paneKey, entry) } + entries.set(paneKey, row) + parts.push(row.projection) + } + const projection = `[${parts.join(',')}]` + cache = { source: map, entries, projection } + return projection + } +} + +// A live agent as the store actually holds it. +function makeEntry(index, updatedAt) { + return { + paneKey: `tab-${index}:leaf-0`, + state: 'working', + prompt: 'implement the feature and run the tests '.repeat(4), + updatedAt, + stateStartedAt: 1740000000000, + agentType: 'claude', + terminalTitle: `agent ${index}`, + stateHistory: Array.from({ length: 20 }, (_value, step) => ({ + state: 'working', + prompt: `step ${step} of the current turn`, + startedAt: 1740000000000 + step, + interrupted: null + })), + toolName: 'shell_command', + toolInput: 'rg --line-number "pattern" src/ '.repeat(8), + interactivePrompt: null, + // The cap the store applies to assistant text. + lastAssistantMessage: 'x'.repeat(8000), + interrupted: null + } +} + +function makeMap(agents) { + const map = {} + for (let index = 0; index < agents; index += 1) { + map[`tab-${index}:leaf-0`] = makeEntry(index, 1740000000000 + index * BUCKET_MS) + } + return map +} + +// One status ping: one entry replaced, the map re-spread, every other entry +// reference-identical — exactly what setAgentStatus produces. +function ping(map, round) { + return { + ...map, + 'tab-0:leaf-0': makeEntry(0, 1740000000000 + BUCKET_MS * (round + 1)) + } +} + +function measure(build, map) { + let current = map + for (let index = 0; index < WARMUP; index += 1) { + current = ping(current, index) + build(current) + } + const samples = [] + for (let round = 0; round < 5; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + current = ping(current, index) + build(current) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[2] +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Mobile agent-status projection, per status ping (one agent changed)') +console.log(`bucket=${BUCKET_MS}ms iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log(`${pad('agents', 8)} ${pad('full', 11)} ${pad('cached', 11)} ${pad('speedup', 9)}`) +for (const agents of [3, 8, 20, 40]) { + const map = makeMap(agents) + const cachedBuilder = makeCachedBuilder() + if (buildFull(map) !== cachedBuilder(map)) { + throw new Error(`projection mismatch at ${agents} agents`) + } + // Why also after a ping: the cold call reuses nothing, so a stale-row bug would + // only surface once the cache is actually exercised. + const pinged = ping(map, 0) + if (buildFull(pinged) !== cachedBuilder(pinged)) { + throw new Error(`projection mismatch after a ping at ${agents} agents`) + } + const full = measure(buildFull, map) + const cached = measure(makeCachedBuilder(), map) + console.log( + `${pad(agents, 8)} ${pad(`${full.toFixed(4)} ms`, 11)} ${pad(`${cached.toFixed(4)} ms`, 11)} ${pad(`${(full / cached).toFixed(1)}x`, 9)}` + ) +} +console.log( + '\nThis runs on the global store subscriber, so the cost is paid per status ping\nand scales with the number of agents running in parallel — the workload this\napp exists for.' +) diff --git a/config/scripts/orca-dev-bin.test.mjs b/config/scripts/orca-dev-bin.test.mjs index 2b0e4f23f0f..afcaf0fb797 100644 --- a/config/scripts/orca-dev-bin.test.mjs +++ b/config/scripts/orca-dev-bin.test.mjs @@ -25,6 +25,7 @@ describe('orca-dev package bin', () => { `fs.writeFileSync(${JSON.stringify(outputPath)}, JSON.stringify({`, ' argv: process.argv.slice(2),', ' userDataPath: process.env.ORCA_USER_DATA_PATH,', + ' devCliInvocation: process.env.ORCA_DEV_CLI_INVOCATION,', ' appExecutable: process.env.ORCA_APP_EXECUTABLE', '}));' ].join('\n'), @@ -47,6 +48,7 @@ describe('orca-dev package bin', () => { expect(JSON.parse(readFileSync(outputPath, 'utf8'))).toEqual({ argv: ['--help'], userDataPath: path.join(root, 'user-data'), + devCliInvocation: '1', appExecutable: path.join(root, 'Electron') }) }) diff --git a/config/scripts/orca-dev.mjs b/config/scripts/orca-dev.mjs index ce5531da6d9..bb4dca441fa 100755 --- a/config/scripts/orca-dev.mjs +++ b/config/scripts/orca-dev.mjs @@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process' import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs' import path from 'node:path' +import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs' const scriptPath = realpathSync(import.meta.filename) const scriptDir = path.dirname(scriptPath) @@ -16,6 +17,8 @@ if (!existsSync(cliEntry)) { } process.env.ORCA_USER_DATA_PATH = process.env.ORCA_DEV_USER_DATA_PATH ?? getDefaultDevUserDataPath() +// Why: custom dev profiles do not necessarily contain "orca-dev" in their path; carry explicit provenance into the CLI. +process.env.ORCA_DEV_CLI_INVOCATION = '1' const electronExecutable = getElectronExecutable() if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) { @@ -23,6 +26,13 @@ if (!process.env.ORCA_APP_EXECUTABLE && isRunnableFile(electronExecutable)) { process.env.ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT = '1' } +// Why: headless `orca-dev serve` skips the Electron dev runner that normally installs terminal CLI shims. +prepareDevCliTerminalWrappers({ + repoRoot, + userDataPath: process.env.ORCA_USER_DATA_PATH, + electronExecutable: process.env.ORCA_APP_EXECUTABLE ?? electronExecutable +}) + const result = spawnSync(process.execPath, [cliEntry, ...process.argv.slice(2)], { stdio: 'inherit', env: process.env diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index bf9e34dc7ab..74fe975048a 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -29,10 +29,14 @@ describe('orchestration skill guidance', () => { const skill = readSkill() const toolBoundary = getSection(skill, 'Tool Boundary') - expect(toolBoundary).toContain( - 'must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject`' + expect(toolBoundary).toContain('must create or bind a Run') + expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`') + expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition') + expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path') + expect(toolBoundary).not.toContain('or `orca orchestration run`') + expect(skill).toContain( + '`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands' ) - expect(toolBoundary).toContain('or `orca orchestration run`') expect(toolBoundary).toContain( 'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features' ) @@ -47,6 +51,20 @@ describe('orchestration skill guidance', () => { ) }) + it('teaches the hard cutover without reviving a legacy executor', () => { + const skill = readSkill() + const migration = getSection(skill, 'Contract Migration') + + expect(migration).toContain('hard cutover') + expect(migration).toContain('effectsApplied') + expect(migration).toContain('skills get orchestration --full') + expect(migration).toContain('Do not retry the rejected command unchanged') + expect(migration).toContain('no longer supervised') + expect(migration).toContain('task-list --run run_legacy_local') + expect(migration).toContain('Read-only inspection never consumes legacy mail') + expect(migration).toContain('does not run a legacy scheduler, translate old writes, or drain') + }) + it('treats long-running worker waits as liveness checkpoints, not failures', () => { const skill = readSkill() @@ -213,13 +231,13 @@ describe('orchestration skill guidance', () => { const messaging = getSection(skill, 'Messaging') const workerTerminals = getSection(skill, 'Worker Terminals') const agentFirstExample = workerTerminals.match( - /```bash\norca worktree create --name --agent codex --json\n[\s\S]*?```/ + /```bash\norca worktree create --name --agent codex --setup run --json\n[\s\S]*?```/ )?.[0] expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:') expect(workerTerminals).toContain('fallback shell + agent pair') expect(workerTerminals).toContain( - 'Repo setup or default-terminal settings may still add tabs or splits' + 'repo setup and default-terminal settings may add intentional tabs or splits' ) expect(workerTerminals).toContain('without configured default tabs') expect(workerTerminals).toContain( @@ -229,12 +247,11 @@ describe('orchestration skill guidance', () => { expect(workerTerminals).not.toContain('ends with **one** agent tab') expect(agentFirstExample).toBeDefined() expect(agentFirstExample).not.toContain('orca terminal list') + expect(agentFirstExample).toContain('agentTerminalHandle') expect(agentFirstExample).toContain('startupTerminal.handle') - expect(messaging).toContain( - 'Use `startupTerminal.handle` from the create response when present' - ) - expect(messaging).toContain('continue with the replacement only') - expect(messaging).toContain('it does not remotely wake another terminal') + expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response') + expect(messaging).toContain('Continue with the replacement handle only') + expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal') expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task') }) }) diff --git a/config/scripts/package-electron-runtime-contract.test.mjs b/config/scripts/package-electron-runtime-contract.test.mjs index 28a73934b59..74ad245ec77 100644 --- a/config/scripts/package-electron-runtime-contract.test.mjs +++ b/config/scripts/package-electron-runtime-contract.test.mjs @@ -527,7 +527,7 @@ describe('Electron runtime package contract', () => { it('installs the Electron package binary in PR checks without changing native module ABI', () => { const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') const parsedWorkflow = parse(prWorkflow) - const installStep = parsedWorkflow.jobs.verify.steps.find( + const installStep = parsedWorkflow.jobs.test.steps.find( (step) => step.name === 'Install Electron package binary for tests' ) @@ -537,7 +537,7 @@ describe('Electron runtime package contract', () => { it('smokes the packaged CLI from outside the checkout in PR checks', () => { const prWorkflow = readFileSync(join(projectDir, '.github/workflows/pr.yml'), 'utf8') const parsedWorkflow = parse(prWorkflow) - const smokeStep = parsedWorkflow.jobs.verify.steps.find( + const smokeStep = parsedWorkflow.jobs.package.steps.find( (step) => step.name === 'Smoke packaged CLI' ) diff --git a/config/scripts/plain-node-entry-guard.test.ts b/config/scripts/plain-node-entry-guard.test.ts new file mode 100644 index 00000000000..a7583e1d470 --- /dev/null +++ b/config/scripts/plain-node-entry-guard.test.ts @@ -0,0 +1,87 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Plugin, Rollup } from 'vite' +import { afterEach, describe, expect, it } from 'vitest' +import { createPlainNodeEntryGuardPlugin } from '../../build-plugins/plain-node-entry-guard' + +let outputDir: string | undefined + +afterEach(() => { + if (outputDir) { + rmSync(outputDir, { recursive: true, force: true }) + outputDir = undefined + } +}) + +function createOutputDir(): string { + outputDir = mkdtempSync(join(tmpdir(), 'orca-plain-node-entry-guard-')) + return outputDir +} + +function createBundle(code = ''): Rollup.OutputBundle { + return { + 'daemon-entry.js': { + type: 'chunk', + code, + dynamicImports: [], + fileName: 'daemon-entry.js', + imports: [], + isEntry: true, + name: 'daemon-entry' + } as Rollup.OutputChunk + } +} + +function runWriteBundle(plugin: Plugin, dir: string, code = ''): void { + const hook = plugin.writeBundle + if (typeof hook !== 'function') { + throw new Error('Expected writeBundle hook') + } + hook.call( + { meta: { watchMode: false } } as never, + { dir } as Rollup.NormalizedOutputOptions, + createBundle(code) + ) +} + +function runCloseBundle(plugin: Plugin): void { + const hook = plugin.closeBundle + if (typeof hook !== 'function') { + throw new Error('Expected closeBundle hook') + } + hook.call({} as never) +} + +describe('plain Node entry guard', () => { + it('smoke-loads the daemon after output files are written', () => { + const dir = createOutputDir() + const plugin = createPlainNodeEntryGuardPlugin() + + expect(() => runWriteBundle(plugin, dir)).not.toThrow() + writeFileSync( + join(dir, 'daemon-entry.js'), + 'console.error("Usage: daemon-entry "); process.exit(1)\n' + ) + + expect(() => runCloseBundle(plugin)).not.toThrow() + }) + + it('runs the deferred smoke from closeBundle', () => { + const dir = createOutputDir() + const plugin = createPlainNodeEntryGuardPlugin() + + runWriteBundle(plugin, dir) + writeFileSync(join(dir, 'daemon-entry.js'), "require('./missing-module')\n") + + expect(() => runCloseBundle(plugin)).toThrow('failed to load under plain Node') + }) + + it('rejects Electron imports during the static bundle scan', () => { + const plugin = createPlainNodeEntryGuardPlugin() + + expect(() => runWriteBundle(plugin, createOutputDir(), 'require("electron")')).toThrow( + 'requires electron' + ) + }) +}) diff --git a/config/scripts/pr-workflow-lint-parity.test.mjs b/config/scripts/pr-workflow-lint-parity.test.mjs new file mode 100644 index 00000000000..648714f244c --- /dev/null +++ b/config/scripts/pr-workflow-lint-parity.test.mjs @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +// Why: pr.yml re-lists the `lint` chain as individual steps so a single failure +// does not mask the rest and oxlint keeps its `--format github` annotations. +// Hand-maintained mirrors drift (#10601: three verifiers never ran on PRs), so +// this gate fails the moment a `lint` step has no counterpart in pr.yml. + +// Flags that only change reporting, so they must not split two otherwise identical commands. +const REPORTING_FLAGS_WITH_VALUE = new Set(['--format', '--reporter']) +const REPORTING_FLAGS = new Set(['--quiet']) +const PACKAGE_RUNNER_TOKENS = new Set(['pnpm', 'npm', 'yarn', 'npx', 'run', 'exec', 'node']) + +function splitCommandChain(command) { + return command + .split(/\n|&&|;/) + .map((part) => part.trim()) + .filter(Boolean) +} + +function canonicalize(command) { + const tokens = command.split(/\s+/) + const canonical = [] + + for (let index = 0; index < tokens.length; index += 1) { + const token = tokens[index] + if (canonical.length === 0 && PACKAGE_RUNNER_TOKENS.has(token)) { + continue + } + if (REPORTING_FLAGS.has(token)) { + continue + } + if (REPORTING_FLAGS_WITH_VALUE.has(token)) { + index += 1 + continue + } + canonical.push(token) + } + + return canonical.join(' ') +} + +/** Expands `pnpm run x` indirection until every entry is a real binary invocation. */ +function resolveLeafCommands(command, scripts, seen = new Set()) { + const leaves = [] + + for (const part of splitCommandChain(command)) { + const scriptName = part.match(/^(?:pnpm|npm|yarn)(?:\s+run)?\s+([\w:-]+)$/)?.[1] + if (scriptName && scripts[scriptName] && !seen.has(scriptName)) { + leaves.push( + ...resolveLeafCommands(scripts[scriptName], scripts, new Set([...seen, scriptName])) + ) + continue + } + leaves.push(canonicalize(part)) + } + + return leaves +} + +describe('PR workflow lint parity', () => { + it('runs every `pnpm lint` step on pull requests', () => { + const { scripts } = JSON.parse(readFileSync('package.json', 'utf8')) + const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) + + // Scan every job: which one hosts the lint steps is an organizational + // detail that has already been renamed once (verify -> static_analysis). + const workflowCommands = new Set( + Object.values(workflow.jobs) + .flatMap((job) => job.steps ?? []) + .filter((step) => typeof step.run === 'string') + .flatMap((step) => resolveLeafCommands(step.run, scripts)) + ) + + const missing = resolveLeafCommands(scripts.lint, scripts).filter( + (leaf) => !workflowCommands.has(leaf) + ) + + expect( + missing, + `.github/workflows/pr.yml is missing lint steps: ${missing.join(', ')}. ` + + 'Add a step for each one so PR CI matches `pnpm lint`.' + ).toEqual([]) + }) +}) diff --git a/config/scripts/pr-workflow-parallelism.test.mjs b/config/scripts/pr-workflow-parallelism.test.mjs new file mode 100644 index 00000000000..c47a7bdb823 --- /dev/null +++ b/config/scripts/pr-workflow-parallelism.test.mjs @@ -0,0 +1,171 @@ +import { globSync, readFileSync } from 'node:fs' +import { parse } from 'yaml' +import { describe, expect, it } from 'vitest' + +const workflow = parse(readFileSync('.github/workflows/pr.yml', 'utf8')) +const dependencyAction = parse( + readFileSync('.github/actions/install-node-dependencies/action.yml', 'utf8') +) +const packageJson = JSON.parse(readFileSync('package.json', 'utf8')) +const shellContractFiles = [ + 'src/main/daemon/shell-ready.test.ts', + 'src/main/providers/local-pty-shell-ready.test.ts', + 'src/main/providers/__tests__/shell-ready-framework-example.test.ts', + 'src/shared/posix-command-path-lookup.test.ts' +] +const patchedNodePtyContractFiles = [ + 'src/main/daemon/node-pty-fd-leak.test.ts', + 'src/main/pty/omp-shell-wrapper.node-pty.test.ts' +] +const nativeShellContractFiles = [...shellContractFiles, ...patchedNodePtyContractFiles] +const testFilePatterns = [ + 'config/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', + 'src/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', + 'tests/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}', + 'tools/**/*.{test,spec}.{js,cjs,mjs,ts,tsx}' +] +const realZshUsage = + /(?:spawnSync|execFileSync|spawn)\(\s*['"](?:\/(?:usr\/)?bin\/)?zsh['"]|spawnSync\(\s*['"]which['"]\s*,\s*\[\s*['"]zsh['"]|name:\s*['"]zsh['"]\s*,\s*path:\s*executablePath/ + +describe('PR workflow parallelism', () => { + it('cancels superseded runs for the same pull request', () => { + expect(workflow.concurrency.group).toBe('pr-checks-${{ github.event.pull_request.number }}') + expect(workflow.concurrency['cancel-in-progress']).toBe(true) + }) + + it('grants the PR workflow read-only repository access', () => { + expect(workflow.permissions).toEqual({ contents: 'read' }) + }) + + it('shards the general test suite across sixteen runners', () => { + expect(workflow.jobs.test.strategy.matrix.shard).toEqual( + Array.from({ length: 16 }, (_, index) => index + 1) + ) + const testStep = workflow.jobs.test.steps.find((step) => step.name === 'Test shard') + + expect(testStep.run).toContain('--shard=${{ matrix.shard }}/${{ strategy.job-total }}') + for (const testFile of nativeShellContractFiles) { + expect(testStep.run).toContain(`--exclude=${testFile}`) + } + }) + + it('runs real-zsh coverage once outside the general shards', () => { + const shellStep = workflow.jobs.shell_contracts.steps.find( + (step) => step.name === 'Test real shell contracts' + ) + const shellInstall = workflow.jobs.shell_contracts.steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + + expect(workflow.jobs.test.steps.some((step) => step.name === 'Install zsh')).toBe(false) + expect(workflow.jobs.shell_contracts.steps.some((step) => step.name === 'Install zsh')).toBe( + true + ) + expect(shellInstall.with['native-runtime']).toBe('node') + for (const testFile of nativeShellContractFiles) { + expect(shellStep.run).toContain(testFile) + } + }) + + it('keeps every real-zsh test in the dedicated shell lane', () => { + const discoveredFiles = globSync(testFilePatterns) + .filter((testFile) => realZshUsage.test(readFileSync(testFile, 'utf8'))) + .sort() + + expect(discoveredFiles).toEqual([...shellContractFiles].sort()) + }) + + it('overlaps bundles with independent output directories', () => { + const buildStep = workflow.jobs.package.steps.find( + (step) => step.name === 'Build package inputs' + ) + + expect(buildStep.run).toContain('scripts=(build:relay build:electron-vite:parallel)') + expect(buildStep.run).toContain('pnpm run "$script" &') + expect( + workflow.jobs.package.steps.find( + (step) => step.name === 'Project web client from renderer build' + ).run + ).toBe('pnpm run build:web-from-renderer') + expect(packageJson.scripts['build:desktop']).toContain('pnpm run build:web-from-renderer') + expect(packageJson.scripts['build:release']).toContain('pnpm run build:web-from-renderer') + }) + + it('restores the pnpm store before dependency installation', () => { + const steps = dependencyAction.runs.steps + const pnpmIndex = steps.findIndex((step) => step.name === 'Setup pnpm') + const nodeIndex = steps.findIndex((step) => step.name === 'Setup Node.js') + + expect(pnpmIndex).toBeLessThan(nodeIndex) + expect(steps[nodeIndex].with.cache).toBe('pnpm') + }) + + it('restores Electron downloads before preparing the package runtime', () => { + const steps = workflow.jobs.package.steps + const cacheIndex = steps.findIndex((step) => step.name === 'Cache electron-builder downloads') + const installIndex = steps.findIndex( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + + expect(cacheIndex).toBeGreaterThanOrEqual(0) + expect(installIndex).toBeGreaterThanOrEqual(0) + expect(cacheIndex).toBeLessThan(installIndex) + }) + + it('prepares each native runtime before its consumers start', () => { + const installFor = (jobName) => + workflow.jobs[jobName].steps.find( + (step) => step.uses === './.github/actions/install-node-dependencies' + ) + + for (const jobName of ['static_analysis', 'typecheck', 'git_compatibility']) { + expect(installFor(jobName).with, jobName).toBeUndefined() + } + expect(installFor('shell_contracts').with['native-runtime']).toBe('node') + expect(installFor('test').with['native-runtime']).toBe('node') + expect(installFor('package').with['native-runtime']).toBe('electron') + + expect( + dependencyAction.runs.steps.find((step) => step.name === 'Use external node-gyp').if + ).toBe("inputs.native-runtime != 'none'") + const dependencyInstall = dependencyAction.runs.steps.find( + (step) => step.name === 'Install dependencies' + ) + expect(dependencyInstall.run).toContain('--no-frozen-lockfile') + expect(dependencyInstall.run).toContain('--ignore-scripts') + expect(dependencyInstall.run).not.toContain('--os=') + expect(dependencyInstall.run).not.toContain('--cpu=') + expect(packageJson.pnpm.supportedArchitectures.os).toEqual( + expect.arrayContaining(['current', 'win32']) + ) + expect(packageJson.pnpm.supportedArchitectures.cpu).toContain('current') + const prepareRuntime = dependencyAction.runs.steps.find( + (step) => step.name === 'Prepare native runtime' + ) + expect(prepareRuntime.if).toBe("inputs.native-runtime != 'none'") + expect(prepareRuntime.run).toContain('ensure-native-runtime.mjs --runtime="$NATIVE_RUNTIME"') + }) + + it('reuses native preparation after the dependency action gate', () => { + const buildStep = workflow.jobs.package.steps.find( + (step) => step.name === 'Build package inputs' + ) + const packageStep = workflow.jobs.package.steps.find( + (step) => step.name === 'Package unpacked app' + ) + + expect(buildStep.run).not.toContain('ensure:electron-runtime') + expect(packageStep.env.ORCA_REUSE_PREPARED_NATIVE_RUNTIME).toBe('1') + }) + + it('keeps verify as the aggregate required check', () => { + expect(workflow.jobs.verify.needs).toEqual([ + 'static_analysis', + 'typecheck', + 'git_compatibility', + 'shell_contracts', + 'test', + 'package' + ]) + }) +}) diff --git a/config/scripts/project-renderer-web-client.mjs b/config/scripts/project-renderer-web-client.mjs new file mode 100644 index 00000000000..77516332523 --- /dev/null +++ b/config/scripts/project-renderer-web-client.mjs @@ -0,0 +1,165 @@ +import { + cpSync, + mkdirSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { dirname, join, posix, resolve, sep } from 'node:path' +import { transform } from 'esbuild' + +const rendererOutput = resolve('out/renderer') +const webOutput = resolve('out/web') +const stagingOutput = resolve(dirname(webOutput), `.web-projection-${process.pid}`) +const manifestPath = join(rendererOutput, '.vite', 'manifest.json') +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) +const selectedFiles = new Set(['web-index.html']) +const visitedEntries = new Set() + +function assertEntryIsolation() { + const entryKeys = new Set( + Object.entries(manifest) + .filter(([, entry]) => entry?.isEntry === true) + .map(([key]) => key) + ) + + for (const sourceEntry of entryKeys) { + const visited = new Set() + const pending = [sourceEntry] + while (pending.length > 0) { + const key = pending.pop() + if (visited.has(key)) { + continue + } + visited.add(key) + const entry = manifest[key] + if (!entry || typeof entry !== 'object') { + throw new Error(`Renderer manifest is missing entry: ${key}`) + } + for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) { + if (entryKeys.has(dependency) && dependency !== sourceEntry) { + throw new Error(`Renderer entry ${sourceEntry} executes entry ${dependency}`) + } + pending.push(dependency) + } + } + } +} + +function addOutputPath(outputPath) { + if ( + typeof outputPath !== 'string' || + outputPath.length === 0 || + outputPath.startsWith('/') || + /^[A-Za-z]:/.test(outputPath) || + outputPath.includes('\\') || + outputPath.split('/').includes('..') + ) { + throw new Error(`Invalid renderer output path: ${String(outputPath)}`) + } + selectedFiles.add(outputPath) +} + +function visitManifestEntry(key) { + if (visitedEntries.has(key)) { + return + } + visitedEntries.add(key) + + const entry = manifest[key] + if (!entry || typeof entry !== 'object') { + throw new Error(`Renderer manifest is missing entry: ${key}`) + } + + addOutputPath(entry.file) + for (const outputPath of [...(entry.css ?? []), ...(entry.assets ?? [])]) { + addOutputPath(outputPath) + } + for (const dependency of [...(entry.imports ?? []), ...(entry.dynamicImports ?? [])]) { + visitManifestEntry(dependency) + } +} + +function listOutputFiles(directory, prefix = '') { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const outputPath = prefix ? join(prefix, entry.name) : entry.name + return entry.isDirectory() + ? listOutputFiles(join(directory, entry.name), outputPath) + : [outputPath.split(sep).join('/')] + }) +} + +function includeReferencedOutputs() { + const candidates = listOutputFiles(rendererOutput).filter( + (outputPath) => !outputPath.startsWith('.vite/') && !outputPath.endsWith('.html') + ) + let foundReference = true + + while (foundReference) { + foundReference = false + for (const selectedFile of selectedFiles) { + if (!/\.(?:css|html|m?js|svg)$/.test(selectedFile)) { + continue + } + const contents = readFileSync(join(rendererOutput, selectedFile), 'utf8') + for (const candidate of candidates) { + if (selectedFiles.has(candidate)) { + continue + } + const localReference = posix.relative(posix.dirname(selectedFile), candidate) + if (contents.includes(candidate) || contents.includes(localReference)) { + selectedFiles.add(candidate) + foundReference = true + } + } + } + } +} + +async function minifyWebOutput() { + await Promise.all( + [...selectedFiles] + .filter((outputPath) => /\.(?:css|m?js)$/.test(outputPath)) + .map(async (outputPath) => { + const targetPath = join(stagingOutput, outputPath) + const loader = outputPath.endsWith('.css') ? 'css' : 'js' + const result = await transform(readFileSync(targetPath, 'utf8'), { + legalComments: 'none', + loader, + minify: true, + target: 'es2020' + }) + writeFileSync(targetPath, result.code) + }) + ) +} + +assertEntryIsolation() +visitManifestEntry('web-index.html') +includeReferencedOutputs() + +rmSync(stagingOutput, { force: true, recursive: true }) +try { + for (const outputPath of selectedFiles) { + const targetPath = join(stagingOutput, outputPath) + mkdirSync(dirname(targetPath), { recursive: true }) + cpSync(join(rendererOutput, outputPath), targetPath) + } + await minifyWebOutput() + + rmSync(webOutput, { force: true, recursive: true }) + renameSync(stagingOutput, webOutput) +} finally { + rmSync(stagingOutput, { force: true, recursive: true }) +} + +const outputBytes = [...selectedFiles].reduce( + (total, outputPath) => total + statSync(join(webOutput, outputPath)).size, + 0 +) +console.log( + `Projected web client: ${selectedFiles.size} files, ${(outputBytes / 1024 / 1024).toFixed(1)} MiB` +) diff --git a/config/scripts/project-renderer-web-client.test.mjs b/config/scripts/project-renderer-web-client.test.mjs new file mode 100644 index 00000000000..49bc0da7c5e --- /dev/null +++ b/config/scripts/project-renderer-web-client.test.mjs @@ -0,0 +1,117 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { spawnSync } from 'node:child_process' +import { afterEach, describe, expect, it } from 'vitest' + +const scriptPath = resolve('config/scripts/project-renderer-web-client.mjs') +const temporaryRoots = [] + +function writeFixtureFile(root, relativePath, contents) { + const targetPath = join(root, relativePath) + mkdirSync(dirname(targetPath), { recursive: true }) + writeFileSync(targetPath, contents) +} + +function createRendererFixture() { + const root = mkdtempSync(join(tmpdir(), 'orca-web-projection-')) + temporaryRoots.push(root) + const manifest = { + 'web-index.html': { + file: 'assets/web-entry.js', + isEntry: true, + imports: ['_web-shared.js'], + dynamicImports: ['src/lazy.ts'] + }, + '_web-shared.js': { + file: 'assets/web-shared.js', + css: ['assets/web.css'], + assets: ['assets/logo.png'] + }, + 'src/lazy.ts': { file: 'assets/lazy.js' }, + 'index.html': { file: 'assets/desktop-entry.js', isEntry: true } + } + + writeFixtureFile(root, 'out/renderer/.vite/manifest.json', JSON.stringify(manifest)) + writeFixtureFile( + root, + 'out/renderer/web-index.html', + '' + ) + writeFixtureFile( + root, + 'out/renderer/assets/web-entry.js', + 'import "./web-shared.js"; new Worker(new URL("editor.worker-fixture.js", import.meta.url));' + ) + writeFixtureFile(root, 'out/renderer/assets/web-shared.js', 'export const value = 1;') + writeFixtureFile(root, 'out/renderer/assets/lazy.js', 'export const lazyValue = true;') + writeFixtureFile(root, 'out/renderer/assets/web.css', '.root { color: red; }') + writeFixtureFile(root, 'out/renderer/assets/logo.png', 'fixture-logo') + writeFixtureFile( + root, + 'out/renderer/assets/editor.worker-fixture.js', + 'self.onmessage = function (event) { self.postMessage(event.data) }' + ) + writeFixtureFile(root, 'out/renderer/assets/desktop-entry.js', 'export const desktop = true;') + writeFixtureFile(root, 'out/web/stale.js', 'stale') + return root +} + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) { + rmSync(root, { force: true, recursive: true }) + } +}) + +describe('renderer web client projection', () => { + it('keeps the build-only manifest out of packaged apps', () => { + const builderConfig = readFileSync(resolve('config/electron-builder.config.cjs'), 'utf8') + + expect(builderConfig).toContain("'!out/renderer/.vite{,/**/*}'") + }) + + it('copies and minifies only the web dependency closure', () => { + const root = createRendererFixture() + const result = spawnSync(process.execPath, [scriptPath], { + cwd: root, + encoding: 'utf8' + }) + + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toContain('Projected web client: 7 files') + expect(existsSync(join(root, 'out/web/web-index.html'))).toBe(true) + expect(existsSync(join(root, 'out/web/assets/editor.worker-fixture.js'))).toBe(true) + expect(existsSync(join(root, 'out/web/assets/logo.png'))).toBe(true) + expect(existsSync(join(root, 'out/web/assets/desktop-entry.js'))).toBe(false) + expect(existsSync(join(root, 'out/web/stale.js'))).toBe(false) + expect(readFileSync(join(root, 'out/web/assets/web.css'), 'utf8')).toBe('.root{color:red}\n') + }) + + it('fails when the renderer manifest omits the web entry', () => { + const root = createRendererFixture() + writeFixtureFile(root, 'out/renderer/.vite/manifest.json', '{}') + const result = spawnSync(process.execPath, [scriptPath], { + cwd: root, + encoding: 'utf8' + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Renderer manifest is missing entry: web-index.html') + }) + + it('rejects renderer entries that execute another entry root', () => { + const root = createRendererFixture() + const manifestPath = join(root, 'out/renderer/.vite/manifest.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + manifest['web-index.html'].dynamicImports.push('index.html') + writeFileSync(manifestPath, JSON.stringify(manifest)) + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: root, + encoding: 'utf8' + }) + + expect(result.status).toBe(1) + expect(result.stderr).toContain('Renderer entry web-index.html executes entry index.html') + }) +}) diff --git a/config/scripts/relay-replay-buffer-benchmark.mjs b/config/scripts/relay-replay-buffer-benchmark.mjs new file mode 100644 index 00000000000..31091ede611 --- /dev/null +++ b/config/scripts/relay-replay-buffer-benchmark.mjs @@ -0,0 +1,218 @@ +#!/usr/bin/env node +// Benchmark: the relay's per-PTY-chunk replay buffer append (src/relay/pty-handler.ts). +// +// appendReplayBuffer did `buffered += data` then, over the cap, `buffered.slice(-CAP)`. +// Once a PTY has produced CAP bytes -- which a long-lived shell does almost immediately +// -- every subsequent chunk flattened and copied the whole 100 KB window. The append is +// called per raw node-pty emission, before batching, so it is per chunk, not per flush. +// +// The fix reuses RecentPtyOutputBuffer: keep chunks, drop from the head, and defer the +// join to read(), which only attach/adopt/revive call. +// +// Both arms are compared for an identical retained tail before timing. +// +// Run with: node config/scripts/relay-replay-buffer-benchmark.mjs +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' + +const ROUNDS = 6 +const SECONDS = Number(process.env.ORCA_REPLAY_BENCH_SECONDS ?? '1') +if (!Number.isFinite(SECONDS) || SECONDS <= 0) { + throw new Error(`ORCA_REPLAY_BENCH_SECONDS must be positive, received ${SECONDS}`) +} + +// Why re-read the sources: the claim is that the relay now appends into a chunk deque +// with the relay's own cap. If either reverts, these numbers stop meaning what they say. +const HANDLER_SOURCE = readFileSync( + new URL('../../src/relay/pty-handler.ts', import.meta.url), + 'utf8' +) +if (!/managed\.buffered\.append\(/.test(HANDLER_SOURCE)) { + throw new Error('relay no longer appends into a chunk deque; this benchmark is stale') +} +const capMatch = HANDLER_SOURCE.match(/REPLAY_BUFFER_MAX = ([\d *]+)/) +if (!capMatch) { + throw new Error('REPLAY_BUFFER_MAX not found; this benchmark is stale') +} +// The regex admits only digits, spaces, and `*`, so the literal is a plain product. +const REPLAY_BUFFER_MAX = capMatch[1] + .split('*') + .map((factor) => Number(factor.trim())) + .reduce((product, factor) => product * factor, 1) +if (!Number.isSafeInteger(REPLAY_BUFFER_MAX) || REPLAY_BUFFER_MAX <= 0) { + throw new Error(`could not read REPLAY_BUFFER_MAX from source, got ${capMatch[1]}`) +} + +// Pre-fix: rolling string, re-sliced once over the cap. +function appendString(state, data) { + if (data.length === 0) { + return state + } + const next = state + data + return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next +} + +// Post-fix: mirrors RecentPtyOutputBuffer's append/read for the relay's options. +class ChunkDeque { + constructor(limit) { + this.chunks = [] + this.headIndex = 0 + this.headOffset = 0 + this.totalLen = 0 + this.limit = limit + } + + append(data) { + if (data.length === 0) { + return + } + if (data.length >= this.limit) { + this.chunks = [data.slice(-this.limit)] + this.headIndex = 0 + this.headOffset = 0 + this.totalLen = this.limit + return + } + this.chunks.push(data) + this.totalLen += data.length + while (this.totalLen > this.limit) { + const headRemaining = this.chunks[this.headIndex].length - this.headOffset + const excess = this.totalLen - this.limit + if (headRemaining <= excess) { + this.chunks[this.headIndex] = '' + this.headIndex += 1 + this.headOffset = 0 + this.totalLen -= headRemaining + } else { + this.headOffset += excess + this.totalLen -= excess + } + } + if (this.headIndex >= 1024) { + this.chunks = this.chunks.slice(this.headIndex) + this.headIndex = 0 + } + } + + read() { + if (this.chunks.length - this.headIndex > 1) { + const retained = this.chunks.slice(this.headIndex) + if (this.headOffset > 0) { + retained[0] = retained[0].slice(this.headOffset) + this.headOffset = 0 + } + this.chunks = [retained.join('')] + this.headIndex = 0 + } else if (this.headOffset > 0) { + this.chunks[this.headIndex] = this.chunks[this.headIndex].slice(this.headOffset) + this.headOffset = 0 + } + return this.chunks[this.headIndex] ?? '' + } +} + +function makeChunks(chunkBytes, chunkCount) { + // Vary content so V8 cannot dedupe or treat the appends as loop-invariant. + return Array.from({ length: chunkCount }, (_value, index) => + `${index}:`.padEnd(chunkBytes, 'abcdefghijklmnopqrstuvwxyz') + ) +} + +// Why pre-saturate: the interesting regime is a PTY that has already filled the window, +// which is where the old form copied 100 KB on literally every chunk. Timing from empty +// would average in a cheap warm-up the real process leaves behind in milliseconds. +function saturate(chunks) { + let stringState = '' + const deque = new ChunkDeque(REPLAY_BUFFER_MAX) + const preload = 'p'.repeat(REPLAY_BUFFER_MAX) + stringState = appendString(stringState, preload) + deque.append(preload) + return { stringState, deque, chunks } +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const mid = sorted.length / 2 + return (sorted[mid - 1] + sorted[mid]) / 2 +} + +function timeString(chunks) { + let state = 'p'.repeat(REPLAY_BUFFER_MAX) + const start = performance.now() + for (const chunk of chunks) { + state = appendString(state, chunk) + } + const elapsed = performance.now() - start + if (state.length !== REPLAY_BUFFER_MAX) { + throw new Error('string arm lost its window') + } + return elapsed +} + +function timeDeque(chunks) { + const deque = new ChunkDeque(REPLAY_BUFFER_MAX) + deque.append('p'.repeat(REPLAY_BUFFER_MAX)) + const start = performance.now() + for (const chunk of chunks) { + deque.append(chunk) + } + const elapsed = performance.now() - start + return elapsed +} + +// Arms alternate which one leads so within-round drift cannot favour either. +function measure(chunks) { + timeString(chunks) + timeDeque(chunks) + const stringSamples = [] + const dequeSamples = [] + for (let round = 0; round < ROUNDS; round += 1) { + if (round % 2 === 0) { + stringSamples.push(timeString(chunks)) + dequeSamples.push(timeDeque(chunks)) + } else { + dequeSamples.push(timeDeque(chunks)) + stringSamples.push(timeString(chunks)) + } + } + return { stringMs: median(stringSamples), dequeMs: median(dequeSamples) } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Relay PTY replay-buffer append, per second of output. Lower is better.') +console.log( + `cap=${(REPLAY_BUFFER_MAX / 1024).toFixed(0)} KiB rounds=${ROUNDS} (per-arm medians, pre-saturated)` +) +console.log( + `${pad('workload', 30)} ${pad('rolling str', 12)} ${pad('chunk deque', 12)} ${pad('speedup', 9)}` +) + +for (const [label, chunkBytes, chunksPerSecond] of [ + ['interactive shell 64B x200', 64, 200], + ['agent TUI 512B x400', 512, 400], + ['build log 4KiB x256 (1 MiB/s)', 4 * 1024, 256], + ['dump 8KiB x512 (4 MiB/s)', 8 * 1024, 512], + ['firehose 16KiB x1024 (16 MiB/s)', 16 * 1024, 1024] +]) { + const chunks = makeChunks(chunkBytes, Math.round(chunksPerSecond * SECONDS)) + const { stringState, deque } = saturate(chunks) + let stringTail = stringState + for (const chunk of chunks) { + stringTail = appendString(stringTail, chunk) + deque.append(chunk) + } + if (deque.read() !== stringTail) { + throw new Error(`retained tail differs for ${label}`) + } + if (stringTail.length !== REPLAY_BUFFER_MAX) { + throw new Error(`fixture never saturated the window for ${label}`) + } + const { stringMs, dequeMs } = measure(chunks) + console.log( + `${pad(label, 30)} ${pad(`${stringMs.toFixed(3)} ms`, 12)} ${pad(`${dequeMs.toFixed(3)} ms`, 12)} ${pad(`${(stringMs / dequeMs).toFixed(0)}x`, 9)}` + ) +} + +console.log( + "\nThis is per PTY, and the relay runs on the user's SSH host. Reads (attach, adopt,\nrevive) now pay the join instead, but those are rare and were already O(window)." +) diff --git a/config/scripts/resolve-7za-path.mjs b/config/scripts/resolve-7za-path.mjs new file mode 100644 index 00000000000..7edf0f65a41 --- /dev/null +++ b/config/scripts/resolve-7za-path.mjs @@ -0,0 +1,102 @@ +#!/usr/bin/env node + +// Why: electron-builder 26.9+ dropped the bundled `7zip-bin` package in favour of a +// toolset downloaded at build time, so the hardcoded `node_modules/7zip-bin/...` path +// the release signing gates used silently stopped resolving (#6487). + +import { createRequire } from 'node:module' +import { existsSync, statSync } from 'node:fs' +import { resolve } from 'node:path' + +const require = createRequire(import.meta.url) + +// Why not existsSync: PowerShell's `Test-Path` is true for directories too, so a +// override pointing at a folder would satisfy both checks and only fail later as +// an opaque exec error inside the gate. +function isFile(path) { + try { + return statSync(path).isFile() + } catch { + return false + } +} + +// Legacy layout, still valid if a transitive dep reintroduces 7zip-bin. The +// package ships `mac/`, not `darwin/`, and keeps a separate ia32 build. +export function legacy7zaRelativePath(platform = process.platform, arch = process.arch) { + if (platform === 'win32') { + return ['node_modules', '7zip-bin', 'win', arch, '7za.exe'] + } + const dir = platform === 'darwin' ? 'mac' : platform + return ['node_modules', '7zip-bin', dir, arch, '7za'] +} + +// app-builder-lib logs download progress to stdout, which would corrupt the +// single-path contract the PowerShell gates parse. Divert it to stderr so a +// cold toolset cache stays debuggable without breaking the caller. +// +// Why refcounted: patching `process.stdout.write` is process-global, so two +// concurrent callers would each capture the other's patched function as their +// "original" and the last `finally` would restore a diverting stub permanently. +let stdoutDivertDepth = 0 +let originalStdoutWrite = null + +async function withStdoutDivertedToStderr(run) { + if (stdoutDivertDepth === 0) { + originalStdoutWrite = process.stdout.write + process.stdout.write = (chunk, encoding, callback) => + process.stderr.write(chunk, encoding, callback) + } + stdoutDivertDepth += 1 + try { + return await run() + } finally { + stdoutDivertDepth -= 1 + if (stdoutDivertDepth === 0) { + process.stdout.write = originalStdoutWrite + originalStdoutWrite = null + } + } +} + +export async function resolve7zaPath(projectDir = process.cwd()) { + const override = process.env.ELECTRON_BUILDER_7ZIP_PATH + if (override && isFile(override)) { + return override + } + + const legacy = resolve(projectDir, ...legacy7zaRelativePath()) + if (isFile(legacy)) { + return legacy + } + + // app-builder-lib reads the same env var and hard-fails on a stale value, so a + // dangling override must be cleared rather than passed through to the download. + const restoreOverride = override !== undefined + if (restoreOverride) { + delete process.env.ELECTRON_BUILDER_7ZIP_PATH + } + try { + // The toolset is cached after the first download, so a release build has + // already paid this cost by the time the signing gate runs. + const { getPath7za } = require('app-builder-lib/out/toolsets/7zip.js') + const toolsetPath = await withStdoutDivertedToStderr(() => getPath7za()) + if (!existsSync(toolsetPath)) { + throw new Error(`app-builder-lib returned a 7za path that does not exist: ${toolsetPath}`) + } + return toolsetPath + } finally { + if (restoreOverride) { + process.env.ELECTRON_BUILDER_7ZIP_PATH = override + } + } +} + +if (import.meta.filename === process.argv[1]) { + try { + process.stdout.write(`${await resolve7zaPath()}\n`) + } catch (error) { + process.stderr.write(`Could not resolve a 7za executable: ${error.message}\n`) + process.exit(1) + } +} diff --git a/config/scripts/resolve-7za-path.test.mjs b/config/scripts/resolve-7za-path.test.mjs new file mode 100644 index 00000000000..e7b1eb40bce --- /dev/null +++ b/config/scripts/resolve-7za-path.test.mjs @@ -0,0 +1,170 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { legacy7zaRelativePath, resolve7zaPath } from './resolve-7za-path.mjs' + +const projectRoot = resolve(import.meta.dirname, '../..') + +describe('7za path resolution for the Windows signing gates (#6487)', () => { + // Why fixtures: 7zip-bin@5.2.0 ships mac/{arm64,x64}, win/{arm64,ia32,x64} + // and linux/{arm,arm64,ia32,x64} — a `darwin/` or arch-collapsed guess + // resolves to nothing, which is how the gate degraded silently in the first place. + it.each([ + ['darwin', 'arm64', 'mac/arm64/7za'], + ['darwin', 'x64', 'mac/x64/7za'], + ['win32', 'x64', 'win/x64/7za.exe'], + ['win32', 'ia32', 'win/ia32/7za.exe'], + ['win32', 'arm64', 'win/arm64/7za.exe'], + ['linux', 'x64', 'linux/x64/7za'], + ['linux', 'arm64', 'linux/arm64/7za'] + ])('maps the %s/%s legacy layout to %s', (platform, arch, expected) => { + expect(legacy7zaRelativePath(platform, arch).join('/')).toBe( + `node_modules/7zip-bin/${expected}` + ) + }) + + it('prefers a real legacy binary over the downloaded toolset', async () => { + // Only meaningful if a transitive dep reintroduces the package. + const legacy = join(projectRoot, ...legacy7zaRelativePath()) + if (!existsSync(legacy)) { + return + } + await expect(resolve7zaPath(projectRoot)).resolves.toBe(legacy) + }) + + it('resolves an executable 7za that can extract an archive', async () => { + const path7za = await resolve7zaPath(projectRoot) + expect(existsSync(path7za)).toBe(true) + + const scratch = mkdtempSync(join(tmpdir(), 'orca 7za resolve ')) + try { + const payloadDir = join(scratch, 'payload') + mkdirSync(payloadDir, { recursive: true }) + writeFileSync(join(payloadDir, 'Orca.exe'), 'not-a-real-pe') + + const archive = join(scratch, 'bundle.7z') + const created = spawnSync(path7za, ['a', archive, payloadDir], { encoding: 'utf8' }) + expect(created.status).toBe(0) + + const outDir = join(scratch, 'out') + const extracted = spawnSync(path7za, ['x', archive, `-o${outDir}`, '-y'], { + encoding: 'utf8' + }) + expect(extracted.status).toBe(0) + expect(existsSync(join(outDir, 'payload', 'Orca.exe'))).toBe(true) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } + }, 120_000) + + it('prefers an explicit ELECTRON_BUILDER_7ZIP_PATH override', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'orca 7za override ')) + const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH + try { + const fake = join(scratch, 'my7za') + writeFileSync(fake, '#!/bin/sh\n') + process.env.ELECTRON_BUILDER_7ZIP_PATH = fake + await expect(resolve7zaPath(projectRoot)).resolves.toBe(fake) + } finally { + if (previous === undefined) { + delete process.env.ELECTRON_BUILDER_7ZIP_PATH + } else { + process.env.ELECTRON_BUILDER_7ZIP_PATH = previous + } + rmSync(scratch, { recursive: true, force: true }) + } + }) + + // Why a directory and not a missing path: PowerShell's `Test-Path $7za` is true + // for directories, so a folder-valued override would clear both the resolver's + // check and the gate's, then fail as an opaque exec error mid-extraction. + it('ignores an override that points at a directory', async () => { + const scratch = mkdtempSync(join(tmpdir(), 'orca 7za dir override ')) + const previous = process.env.ELECTRON_BUILDER_7ZIP_PATH + try { + process.env.ELECTRON_BUILDER_7ZIP_PATH = scratch + const resolved = await resolve7zaPath(projectRoot) + expect(resolved).not.toBe(scratch) + expect(statSync(resolved).isFile()).toBe(true) + } finally { + if (previous === undefined) { + delete process.env.ELECTRON_BUILDER_7ZIP_PATH + } else { + process.env.ELECTRON_BUILDER_7ZIP_PATH = previous + } + rmSync(scratch, { recursive: true, force: true }) + } + }, 120_000) + + // Why concurrency: the stdout diversion patches a process-global function. A + // naive save/restore lets the first finisher reinstate a still-diverting stub + // as "the original", permanently swallowing stdout for the rest of the process. + it('restores stdout after concurrent resolutions', async () => { + const before = process.stdout.write + await Promise.all([ + resolve7zaPath(projectRoot), + resolve7zaPath(projectRoot), + resolve7zaPath(projectRoot) + ]) + expect(process.stdout.write).toBe(before) + }, 120_000) + + // Why a subprocess: app-builder-lib memoises the resolved toolset, so an in-process + // assertion passes on the cached value even when a dangling override would abort a + // cold release runner. + it('ignores an override that points at a missing file, in a cold process', () => { + const dangling = join(tmpdir(), 'orca-7za-does-not-exist') + const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], { + cwd: projectRoot, + encoding: 'utf8', + env: { ...process.env, ELECTRON_BUILDER_7ZIP_PATH: dangling }, + timeout: 120_000 + }) + + expect(result.stderr ?? '').not.toContain('does not exist') + expect(result.status).toBe(0) + const resolved = result.stdout.trim() + expect(resolved).not.toBe(dangling) + expect(existsSync(resolved)).toBe(true) + }, 120_000) + + it('prints exactly one clean line the PowerShell gate can consume', () => { + const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], { + cwd: projectRoot, + encoding: 'utf8', + timeout: 120_000 + }) + + expect(result.status).toBe(0) + expect(result.stdout.trimEnd().split('\n')).toHaveLength(1) + expect(existsSync(result.stdout.trim())).toBe(true) + }, 120_000) + + // Why a cold cache with VITEST unset: app-builder-lib prints download + // progress to stdout, and builder-util suppresses that logging under Vitest — + // so an inherited VITEST makes this exact failure invisible. `$7za = (node + // ...).Trim()` would otherwise receive two lines and the gate would break on + // any runner whose toolset cache was evicted or repaired. + it('keeps stdout to one path even when the toolset cache is cold', () => { + const cache = mkdtempSync(join(tmpdir(), 'orca 7za cold cache ')) + try { + const { VITEST: _vitest, ...envWithoutVitest } = process.env + const result = spawnSync(process.execPath, ['config/scripts/resolve-7za-path.mjs'], { + cwd: projectRoot, + encoding: 'utf8', + env: { ...envWithoutVitest, ELECTRON_BUILDER_CACHE: cache }, + timeout: 300_000 + }) + + expect(result.status).toBe(0) + const lines = result.stdout.trimEnd().split('\n') + expect(lines).toHaveLength(1) + expect(existsSync(lines[0])).toBe(true) + } finally { + rmSync(cache, { recursive: true, force: true }) + } + }, 300_000) +}) diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 9b8aad89557..0a578140593 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -1,7 +1,6 @@ import { execFileSync, spawn } from 'node:child_process' import { createHash } from 'node:crypto' import { - chmodSync, cpSync, existsSync, lstatSync, @@ -17,6 +16,7 @@ import { import net from 'node:net' import { createRequire } from 'node:module' import path from 'node:path' +import { prepareDevCliTerminalWrappers } from './dev-cli-terminal-wrapper.mjs' // Why: Electron-based hosts (e.g. Claude Code, VS Code) set // ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into @@ -314,35 +314,12 @@ function getDevUserDataPath() { } function prepareDevCliWrapper() { - const binDir = path.join(repoRoot, 'out', 'bin') - mkdirSync(binDir, { recursive: true }) const userDataPath = getDevUserDataPath() - const userDataBinDir = path.join(userDataPath, 'cli', 'bin') - const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js') - const electronBin = getElectronExecutable() - - if (process.platform === 'win32') { - writeFileSync( - path.join(binDir, 'orca-dev.cmd'), - `@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nset "ORCA_APP_EXECUTABLE=${electronBin}"\r\nset "ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1"\r\nnode "${cliPath}" %*\r\n`, - 'utf8' - ) - } else { - const wrapperContent = `#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexport ORCA_APP_EXECUTABLE=${JSON.stringify(electronBin)}\nexport ORCA_APP_EXECUTABLE_NEEDS_APP_ROOT=1\nexec node ${JSON.stringify(cliPath)} "$@"\n` - const wrapperPath = path.join(binDir, 'orca-dev') - writeFileSync(wrapperPath, wrapperContent, 'utf8') - chmodSync(wrapperPath, 0o755) - - mkdirSync(userDataBinDir, { recursive: true }) - for (const commandName of ['orca-dev', 'orca']) { - const userDataWrapperPath = path.join(userDataBinDir, commandName) - // Why: dev Orca terminals prepend this directory to PATH; refreshing the - // `orca` alias prevents stale global/userData wrappers from hijacking - // Orca-owned commands such as `orca claude-teams`. - writeFileSync(userDataWrapperPath, wrapperContent, 'utf8') - chmodSync(userDataWrapperPath, 0o755) - } - } + const { binDir } = prepareDevCliTerminalWrappers({ + repoRoot, + userDataPath, + electronExecutable: getElectronExecutable() + }) process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}` console.log(`[orca-dev] Prepared wrapper in ${binDir}`) diff --git a/config/scripts/run-electron-vite-targets-in-parallel.mjs b/config/scripts/run-electron-vite-targets-in-parallel.mjs new file mode 100644 index 00000000000..c3dedcaf8e7 --- /dev/null +++ b/config/scripts/run-electron-vite-targets-in-parallel.mjs @@ -0,0 +1,43 @@ +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const buildScript = fileURLToPath(new URL('./run-electron-vite-build.mjs', import.meta.url)) +const targetConfig = fileURLToPath(new URL('../electron-vite-target.config.ts', import.meta.url)) +const targets = ['main', 'preload', 'renderer'] + +function buildTarget(target) { + return new Promise((resolve, reject) => { + const child = spawn( + process.execPath, + [buildScript, '--config', targetConfig, '--ignoreConfigWarning'], + { + stdio: 'inherit', + env: { + ...process.env, + ORCA_ELECTRON_VITE_TARGET: target + } + } + ) + + child.on('error', reject) + child.on('exit', (code, signal) => { + if (signal) { + reject(new Error(`Electron Vite ${target} build exited with signal ${signal}`)) + } else if (code !== 0) { + reject(new Error(`Electron Vite ${target} build exited with code ${code}`)) + } else { + resolve() + } + }) + }) +} + +const results = await Promise.allSettled(targets.map(buildTarget)) +const failures = results.filter((result) => result.status === 'rejected') + +if (failures.length > 0) { + for (const failure of failures) { + console.error(failure.reason) + } + process.exit(1) +} diff --git a/config/scripts/source-control-path-sort-benchmark.mjs b/config/scripts/source-control-path-sort-benchmark.mjs new file mode 100644 index 00000000000..039bb4d6f08 --- /dev/null +++ b/config/scripts/source-control-path-sort-benchmark.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Benchmark: cost of sorting the Source Control changed-file list. +// +// compareGitStatusEntries called `a.path.localeCompare(b.path, undefined, {numeric:true})`. +// Passing an options object makes each call resolve a fresh ICU collator, so a +// sort paid for one per O(n log n) comparison. The fix hoists a single +// Intl.Collator, which is the idiom TaskPage.tsx already uses for Jira labels. +// +// The sort runs in a useMemo keyed on the entry list, so it re-runs on every git +// refresh that changes the working tree. +// +// Both arms sort the same generated list and their outputs are compared before +// timing, so a comparator that changed the order cannot be reported as a win. +import { execFileSync } from 'node:child_process' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const ITERATIONS = Number(process.env.ORCA_SC_SORT_BENCH_ITERATIONS ?? '25') +const WARMUP = Number(process.env.ORCA_SC_SORT_BENCH_WARMUP ?? '5') + +for (const [name, value] of [ + ['ORCA_SC_SORT_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_SC_SORT_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function conflictRank(entry) { + if (entry.conflictStatus === 'unresolved') { + return 0 + } + if (entry.conflictStatus === 'resolved_locally') { + return 1 + } + return 2 +} + +// Pre-fix: resolves a collator per comparison. +function compareBefore(a, b) { + return ( + conflictRank(a) - conflictRank(b) || a.path.localeCompare(b.path, undefined, { numeric: true }) + ) +} + +// Post-fix: one hoisted collator, mirroring source-control-status-sort.ts. +const collator = new Intl.Collator(undefined, { numeric: true }) +function compareAfter(a, b) { + return conflictRank(a) - conflictRank(b) || collator.compare(a.path, b.path) +} + +// Why real repo paths in git's own order: `git status` emits byte-sorted paths, +// and a shuffled fixture inflates the win — a nearly-sorted array is the case +// this actually has to beat. +const REPO_PATHS = execFileSync('git', ['ls-files'], { + cwd: fileURLToPath(new URL('../..', import.meta.url)), + maxBuffer: 256 * 1024 * 1024 +}) + .toString() + .split('\n') + .filter(Boolean) + +function makeEntries(count) { + const step = Math.max(1, Math.floor(REPO_PATHS.length / count)) + const paths = [] + for (let index = 0; index < REPO_PATHS.length && paths.length < count; index += step) { + paths.push(REPO_PATHS[index]) + } + return paths.map((path, index) => ({ + path, + area: 'unstaged', + status: 'modified', + ...(index % 37 === 0 ? { conflictStatus: 'unresolved' } : {}) + })) +} + +function measure(compare, entries) { + for (let index = 0; index < WARMUP; index += 1) { + ;[...entries].sort(compare) + } + const samples = [] + for (let round = 0; round < 5; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + ;[...entries].sort(compare) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[2] +} + +const pad = (value, width) => String(value).padStart(width) +console.log('Source Control changed-file sort, per git refresh. Lower is better.') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log(`${pad('files', 7)} ${pad('per-call', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}`) + +// Sizes from the real distribution over 7,324 non-merge commits on this repo: +// p50 3, p75 7, p90 17, p95 26, p99 63, max 1626. +for (const count of [3, 17, 26, 63, 308, 1000]) { + const entries = makeEntries(count) + const before = [...entries].sort(compareBefore).map((entry) => entry.path) + const after = [...entries].sort(compareAfter).map((entry) => entry.path) + if (before.join('\n') !== after.join('\n')) { + throw new Error(`sort order differs at ${count} files`) + } + const beforeMs = measure(compareBefore, entries) + const afterMs = measure(compareAfter, entries) + console.log( + `${pad(count, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} +console.log( + '\nSizes are the real changed-file distribution over 7,324 non-merge commits on\nthis repo (p50 3, p90 17, p95 26, p99 63), so the top rows are the common case.\nThis times the sort alone; the sort is roughly 85% of the Source Control\nprojection chain, so the end-to-end memo win is smaller than these ratios.' +) diff --git a/config/scripts/ssh-watch-fanout-benchmark.mjs b/config/scripts/ssh-watch-fanout-benchmark.mjs new file mode 100644 index 00000000000..c2285846194 --- /dev/null +++ b/config/scripts/ssh-watch-fanout-benchmark.mjs @@ -0,0 +1,190 @@ +#!/usr/bin/env node +// Benchmark: routing one `fs.changed` relay notification to SSH watch registrations. +// +// routeSshFilesystemWatchNotification called isPathInsideOrEqual(root, event.path) +// for every (registration x event) pair. That helper NFC-normalizes BOTH sides, so +// each event path was re-normalized once per watch root, and each root was +// re-normalized once per event -- O(roots * events) normalizations for what is +// O(roots + events) distinct work. +// +// The fix normalizes each event path once up front and builds one pre-normalized +// matcher per root, leaving only string compare in the inner loop. +// +// This is a hot path on SSH: the relay watcher batches up to MAX_BATCHED_WATCHER_EVENTS +// per notify, and a single `git checkout` or `pnpm install` on the remote host emits +// thousands of paths through it. +// +// Both arms are run against the same inputs and their outputs are compared before +// timing, so a matcher that changed which events route where cannot be reported as +// a win. The normalizer is imported from the real module (via tsx) rather than +// re-modelled here, so folding-rule drift cannot silently invalidate the result. +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url)) +const ITERATIONS = Number(process.env.ORCA_SSH_WATCH_BENCH_ITERATIONS ?? '200') +const WARMUP = Number(process.env.ORCA_SSH_WATCH_BENCH_WARMUP ?? '30') + +for (const [name, value] of [ + ['ORCA_SSH_WATCH_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_SSH_WATCH_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +// Why re-read the source: this benchmark's whole claim is that the normalizer is +// the expensive part. If someone makes it cheap (or drops the NFC fold), the +// numbers below stop meaning what the header says, so fail loudly instead. +const PATH_SOURCE = readFileSync( + new URL('../../src/shared/cross-platform-path.ts', import.meta.url), + 'utf8' +) +for (const marker of ['normalize(', 'createNormalizedPathInsideOrEqualMatcher']) { + if (!PATH_SOURCE.includes(marker)) { + throw new Error(`cross-platform-path.ts no longer contains ${marker}; this benchmark is stale`) + } +} + +// Import the real normalizer so both arms fold paths exactly as production does. +const { + normalizeRuntimePathForComparison, + isPathInsideOrEqual, + createNormalizedPathInsideOrEqualMatcher +} = await import(new URL('../../src/shared/cross-platform-path.ts', import.meta.url).href) + +// Pre-fix: mirrors the original routeSshFilesystemWatchNotification inner loop. +function routeBefore(roots, events, sink) { + for (const rootPath of roots) { + const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath)) + if (matching.length > 0) { + sink(rootPath, matching) + } + } +} + +// Post-fix: mirrors the current implementation. +function routeAfter(roots, events, sink) { + const normalizedEvents = events.map((event) => ({ + event, + normalizedPath: normalizeRuntimePathForComparison(event.absolutePath) + })) + for (const rootPath of roots) { + const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(rootPath) + const matching = normalizedEvents + .filter(({ normalizedPath }) => isInsideRoot(normalizedPath)) + .map(({ event }) => event) + if (matching.length > 0) { + sink(rootPath, matching) + } + } +} + +// Why real repo paths: path length and segment count drive normalization cost, and +// a synthetic `/a/b/c` fixture would understate it against real source trees. +const REPO_PATHS = execFileSync('git', ['ls-files'], { + cwd: REPO_ROOT, + maxBuffer: 256 * 1024 * 1024 +}) + .toString() + .split('\n') + .filter(Boolean) + +// A remote host running several worktrees: each is its own watch root, and the +// file explorer plus the worktree-base-directory watcher both register. +function makeRoots(count) { + return Array.from({ length: count }, (_, index) => `/home/dev/worktrees/orca-${index}`) +} + +function makeEvents(roots, count) { + const events = [] + for (let index = 0; index < count; index += 1) { + // Spread events across roots so most roots match some events, as a real + // multi-worktree checkout does. Paths outside any root also occur (node_modules + // of a sibling checkout), so include a slice of those too. + const root = index % 11 === 0 ? '/home/dev/other-checkout' : roots[index % roots.length] + events.push({ + kind: 'update', + absolutePath: `${root}/${REPO_PATHS[index % REPO_PATHS.length]}` + }) + } + return events +} + +function collect(roots, events, route) { + const seen = [] + route(roots, events, (rootPath, matching) => + seen.push(`${rootPath} ${matching.map((event) => event.absolutePath).join(',')}`) + ) + return seen.join('\n') +} + +// Why interleaved: running one arm's whole batch before the other's lets CPU +// frequency drift and background load correlate with the arm being measured. On a +// loaded machine that alone swung the 12x200 row between 6.7x and 23.3x. Alternating +// per round and taking per-arm medians keeps the drift common to both. +function measureInterleaved(roots, events) { + const noop = () => undefined + for (let index = 0; index < WARMUP; index += 1) { + routeBefore(roots, events, noop) + routeAfter(roots, events, noop) + } + const beforeSamples = [] + const afterSamples = [] + for (let round = 0; round < 5; round += 1) { + let start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + routeBefore(roots, events, noop) + } + beforeSamples.push((performance.now() - start) / ITERATIONS) + + start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + routeAfter(roots, events, noop) + } + afterSamples.push((performance.now() - start) / ITERATIONS) + } + beforeSamples.sort((a, b) => a - b) + afterSamples.sort((a, b) => a - b) + return { beforeMs: beforeSamples[2], afterMs: afterSamples[2] } +} + +const pad = (value, width) => String(value).padStart(width) +console.log('SSH fs.changed fan-out, per relay notification. Lower is better.') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log( + `${pad('roots', 6)} ${pad('events', 7)} ${pad('per-pair', 11)} ${pad('hoisted', 11)} ${pad('speedup', 9)}` +) + +// roots x events: 3x20 is a typical few-worktree session with a small save; the +// larger rows are a remote `git checkout` or `pnpm install` storm, which the relay +// batches up to MAX_BATCHED_WATCHER_EVENTS (5,000) per notification. +for (const [rootCount, eventCount] of [ + [3, 20], + [6, 50], + [12, 200], + [25, 500], + [25, 5000] +]) { + const roots = makeRoots(rootCount) + const events = makeEvents(roots, eventCount) + const before = collect(roots, events, routeBefore) + const after = collect(roots, events, routeAfter) + if (before !== after) { + throw new Error(`routing differs at ${rootCount} roots x ${eventCount} events`) + } + if (!before.includes(' ')) { + throw new Error(`fixture routed nothing at ${rootCount} roots x ${eventCount} events`) + } + const { beforeMs, afterMs } = measureInterleaved(roots, events) + console.log( + `${pad(rootCount, 6)} ${pad(eventCount, 7)} ${pad(`${beforeMs.toFixed(3)} ms`, 11)} ${pad(`${afterMs.toFixed(3)} ms`, 11)} ${pad(`${(beforeMs / afterMs).toFixed(1)}x`, 9)}` + ) +} + +console.log( + '\nThis times routing only. The saving scales with roots x events, so it is small\nfor a single-worktree session and largest during a remote checkout storm, which\nis exactly when the main process is already busy.' +) diff --git a/config/scripts/terminal-control-strip-benchmark.mjs b/config/scripts/terminal-control-strip-benchmark.mjs new file mode 100644 index 00000000000..c06eb2a7ff0 --- /dev/null +++ b/config/scripts/terminal-control-strip-benchmark.mjs @@ -0,0 +1,202 @@ +#!/usr/bin/env node +// Mirrors the complete pre/post stripTerminalControl paths at their production size caps. +import { performance } from 'node:perf_hooks' + +const ESC = String.fromCharCode(0x1b) +const BEL = String.fromCharCode(0x07) +const ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~]|\\][^${BEL}]*(?:${BEL}|${ESC}\\\\))`, + 'g' +) +const INCOMPLETE_ANSI_ESCAPE_RE = new RegExp( + `${ESC}(?:\\[[0-?]*[ -/]*|\\][^${BEL}${ESC}]*|\\S?)?$`, + 'g' +) +const HISTORY_LIMIT = 300 +const SCAN_LIMIT = 4096 +const SAMPLE_ID_LENGTH = 24 +const ITERATIONS = Number(process.env.ORCA_STRIP_BENCH_ITERATIONS ?? '501') +let resultChecksum = 0 +let validatedPairs = 0 + +if (!Number.isSafeInteger(ITERATIONS) || ITERATIONS <= 0) { + throw new Error(`ORCA_STRIP_BENCH_ITERATIONS must be a positive integer, got ${ITERATIONS}`) +} + +function isStrippedCode(code) { + return (code <= 0x1f && code !== 0x0a && code !== 0x0d) || (code >= 0x7f && code <= 0x9f) +} + +function terminalControlMayAffectText(data) { + for (let index = 0; index < data.length; index += 1) { + const code = data.charCodeAt(index) + if ( + code === 0x0d || + code === 0x1b || + (code <= 0x1f && code !== 0x0a) || + (code >= 0x7f && code <= 0x9f) + ) { + return true + } + } + return false +} + +function stripPerChar(data) { + if (!terminalControlMayAffectText(data)) { + return data + } + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + let output = '' + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (isStrippedCode(withoutAnsi.charCodeAt(index))) { + continue + } + output += withoutAnsi[index] + } + return output +} + +function stripSliceRuns(data) { + if (!terminalControlMayAffectText(data)) { + return data + } + const withoutAnsi = data.replace(ANSI_ESCAPE_RE, '').replace(INCOMPLETE_ANSI_ESCAPE_RE, '') + let output = '' + let runStart = 0 + for (let index = 0; index < withoutAnsi.length; index += 1) { + if (isStrippedCode(withoutAnsi.charCodeAt(index))) { + if (index > runStart) { + output += withoutAnsi.slice(runStart, index) + } + runStart = index + 1 + } + } + return runStart === 0 ? withoutAnsi : output + withoutAnsi.slice(runStart) +} + +function fixedSampleId(sampleId) { + return `sample:${sampleId}`.padEnd(SAMPLE_ID_LENGTH, '_').slice(0, SAMPLE_ID_LENGTH) +} + +function makeTuiFixture(length, sampleId, strippedControl) { + const lines = [ + `${strippedControl}\x1b[35m✻ Thinking...\x1b[0m\r\n`, + ' ⏺ Running tests... 42 passed, 0 failed\r\n' + ] + let text = `${fixedSampleId(sampleId)}\r\n` + for (let lineIndex = 0; ; lineIndex += 1) { + const next = lines[lineIndex % lines.length] + if (text.length + next.length > length) { + break + } + text += next + } + return text + 'x'.repeat(length - text.length) +} + +function makeDenseFixture(length, sampleId, strippedControl) { + const prefix = `\x1b[35m${fixedSampleId(sampleId)}` + const suffix = '\x1b[0m' + const bodyLength = length - prefix.length - suffix.length + const body = `x${strippedControl}`.repeat(Math.floor(bodyLength / 2)) + return `${prefix}${body}${bodyLength % 2 === 0 ? '' : 'x'}${suffix}` +} + +function median(samples) { + const sorted = [...samples].sort((a, b) => a - b) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +function measure(strip, fixture) { + const start = performance.now() + const output = strip(fixture) + return { elapsed: performance.now() - start, output } +} + +function consumeOutput(output) { + resultChecksum = Math.imul(resultChecksum ^ output.length, 16777619) >>> 0 + resultChecksum ^= output.charCodeAt(Math.floor(output.length / 2)) +} + +function recordPair(fixture, sampleId, perCharFirst, samples) { + const perCharFixture = fixture.make(sampleId, perCharFirst ? '\x01' : '\x02') + const sliceRunsFixture = fixture.make(sampleId, perCharFirst ? '\x02' : '\x01') + if ( + perCharFixture === sliceRunsFixture || + perCharFixture.length !== fixture.length || + sliceRunsFixture.length !== fixture.length + ) { + throw new Error(`invalid inputs for ${fixture.label}, sample ${sampleId}`) + } + let perCharResult + let sliceRunsResult + if (perCharFirst) { + perCharResult = measure(stripPerChar, perCharFixture) + sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) + } else { + sliceRunsResult = measure(stripSliceRuns, sliceRunsFixture) + perCharResult = measure(stripPerChar, perCharFixture) + } + if (perCharResult.output !== sliceRunsResult.output) { + throw new Error(`strip mismatch for ${fixture.label}, sample ${sampleId}`) + } + consumeOutput(perCharResult.output) + consumeOutput(sliceRunsResult.output) + validatedPairs += 1 + samples.perChar.push(perCharResult.elapsed) + samples.sliceRuns.push(sliceRunsResult.elapsed) +} + +const denseBodyLength = SCAN_LIMIT - '\x1b[35m'.length - SAMPLE_ID_LENGTH - '\x1b[0m'.length +const denseControlPercent = ((Math.floor(denseBodyLength / 2) / SCAN_LIMIT) * 100).toFixed(1) +const fixtures = [ + { + label: `${HISTORY_LIMIT} history TUI`, + length: HISTORY_LIMIT, + make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT, sampleId, control) + }, + { + label: `${HISTORY_LIMIT + 1} boundary TUI`, + length: HISTORY_LIMIT + 1, + make: (sampleId, control) => makeTuiFixture(HISTORY_LIMIT + 1, sampleId, control) + }, + { + label: `${SCAN_LIMIT} scan TUI`, + length: SCAN_LIMIT, + make: (sampleId, control) => makeTuiFixture(SCAN_LIMIT, sampleId, control) + }, + { + label: `${SCAN_LIMIT} scan ${denseControlPercent}% C0`, + length: SCAN_LIMIT, + make: (sampleId, control) => makeDenseFixture(SCAN_LIMIT, sampleId, control) + } +] + +const pad = (value, width) => String(value).padStart(width) +console.log('Complete stripTerminalControl path. Lower is better.') +console.log( + `iterations=${ITERATIONS} (${ITERATIONS * 2} first-touch samples/implementation, counterbalanced median)` +) +console.log( + `${pad('fixture', 25)} ${pad('per-char', 11)} ${pad('slice runs', 12)} ${pad('speedup', 9)}` +) + +for (const fixture of fixtures) { + const samples = { perChar: [], sliceRuns: [] } + for (let index = 0; index < ITERATIONS; index += 1) { + const orders = index % 2 === 0 ? [true, false] : [false, true] + for (const perCharFirst of orders) { + const orderLabel = perCharFirst ? 'per-first' : 'slice-first' + recordPair(fixture, `${index}:${orderLabel}`, perCharFirst, samples) + } + } + const perChar = median(samples.perChar) + const sliceRuns = median(samples.sliceRuns) + console.log( + `${pad(fixture.label, 25)} ${pad(`${(perChar * 1000).toFixed(1)} us`, 11)} ${pad(`${(sliceRuns * 1000).toFixed(1)} us`, 12)} ${pad(`${(perChar / sliceRuns).toFixed(2)}x`, 9)}` + ) +} +console.log(`\nvalidated=${validatedPairs} measured pairs, result checksum=${resultChecksum >>> 0}`) +console.log('Production calls are bounded to 4096, 4096, 300, and 301 code units.') diff --git a/config/scripts/terminal-pr-link-carry-benchmark.mjs b/config/scripts/terminal-pr-link-carry-benchmark.mjs new file mode 100644 index 00000000000..568cf0981ab --- /dev/null +++ b/config/scripts/terminal-pr-link-carry-benchmark.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +// Benchmark: per-chunk cost of the GitHub PR-link carry scan on the PTY output path. +// +// createTerminalGitHubPRLinkDetector() runs on every PTY chunk (renderer +// pty-connection + parked-terminal-byte-watcher). Before the fix, +// getPotentialGitHubPRCarry() ran `lastIndexOf` for BOTH http scheme prefixes +// across the entire combined chunk — even on the early-out path where the chunk +// provably has no `/pull/`. The carry it returns is always a suffix of at most +// MAX_CARRY_LENGTH (512) bytes, so every byte scanned before +// `length - 512` was guaranteed-wasted work. +// +// The fix bounds the scan to that trailing window. This script measures the +// scan itself across chunk sizes so the saved work is quantified. +// +// carryBefore/carryAfter are mirrors: node cannot import the .ts source, which is +// why the sibling benchmarks in this directory inline their subject too. The +// constants below are re-read from the real module at startup so a drifted cap or +// scheme list fails loudly here instead of quietly benchmarking dead code. +import { readFileSync } from 'node:fs' +import { performance } from 'node:perf_hooks' +import { fileURLToPath } from 'node:url' + +const DETECTOR_SOURCE = readFileSync( + fileURLToPath(new URL('../../src/shared/terminal-github-pr-link-detector.ts', import.meta.url)), + 'utf8' +) + +function readMirroredConstants(source) { + const cap = source.match(/const MAX_CARRY_LENGTH = (\d+)/) + const prefixes = source.match(/const HTTP_SCHEME_PREFIXES = \[([^\]]+)\]/) + if (!cap || !prefixes) { + throw new Error( + 'terminal-github-pr-link-detector.ts no longer exposes MAX_CARRY_LENGTH / HTTP_SCHEME_PREFIXES in the expected shape; re-sync this benchmark with the implementation.' + ) + } + return { + maxCarryLength: Number(cap[1]), + httpSchemePrefixes: prefixes[1] + .split(',') + .map((entry) => entry.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean) + } +} + +const { maxCarryLength: MAX_CARRY_LENGTH, httpSchemePrefixes: HTTP_SCHEME_PREFIXES } = + readMirroredConstants(DETECTOR_SOURCE) +const ITERATIONS = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_ITERATIONS ?? '2000', 10) +const WARMUP = Number.parseInt(process.env.ORCA_PR_CARRY_BENCH_WARMUP ?? '200', 10) + +for (const [name, value] of [ + ['ORCA_PR_CARRY_BENCH_ITERATIONS', ITERATIONS], + ['ORCA_PR_CARRY_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +function hasTerminalUrlWhitespace(value, start, end) { + for (let index = start; index < end; index += 1) { + if (/\s/.test(value.charAt(index))) { + return true + } + } + return false +} + +function endsWithHttpSchemePrefixFragment(value) { + for (const prefix of HTTP_SCHEME_PREFIXES) { + for (let length = Math.min(prefix.length - 1, value.length); length > 0; length--) { + if (value.endsWith(prefix.slice(0, length))) { + return value.slice(value.length - length) + } + } + } + return '' +} + +// Pre-fix implementation, kept verbatim for comparison. +function carryBefore(value) { + const schemeIndex = Math.max(...HTTP_SCHEME_PREFIXES.map((prefix) => value.lastIndexOf(prefix))) + if (schemeIndex !== -1) { + const tailLength = value.length - schemeIndex + if (tailLength > MAX_CARRY_LENGTH) { + return '' + } + return hasTerminalUrlWhitespace(value, schemeIndex, value.length) + ? '' + : value.slice(schemeIndex) + } + return endsWithHttpSchemePrefixFragment(value) +} + +// Post-fix implementation, mirroring src/shared/terminal-github-pr-link-detector.ts. +function lastIndexOfHttpScheme(value, fromIndex) { + let lastIndex = -1 + for (const prefix of HTTP_SCHEME_PREFIXES) { + const candidate = + fromIndex === undefined ? value.lastIndexOf(prefix) : value.lastIndexOf(prefix, fromIndex) + if (candidate > lastIndex) { + lastIndex = candidate + } + } + return lastIndex +} + +function carryAfter(value) { + const windowStart = value.length > MAX_CARRY_LENGTH ? value.length - MAX_CARRY_LENGTH : 0 + const window = windowStart === 0 ? value : value.slice(windowStart) + const schemeIndexInWindow = lastIndexOfHttpScheme(window) + if (schemeIndexInWindow !== -1) { + const schemeIndex = windowStart + schemeIndexInWindow + return hasTerminalUrlWhitespace(value, schemeIndex, value.length) + ? '' + : value.slice(schemeIndex) + } + const fragment = endsWithHttpSchemePrefixFragment(window) + if (fragment === '' || windowStart === 0) { + return fragment + } + return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : '' +} + +const GITHUB_PR_PATH_MARKER = '/pull/' + +// Agent TUI output: no scheme anywhere, which is the overwhelmingly common case +// and the one where the old code scanned the full chunk to return ''. `tail` +// forces the chunk to end mid-scheme so the fallback branch is measured too. +function makeChunk(bytes, tail = '') { + const line = 'build output line with some text and punctuation, id=12345\n' + const filled = line.repeat(Math.ceil(bytes / line.length)).slice(0, bytes) + return tail ? filled.slice(0, bytes - tail.length) + tail : filled +} + +// Why measure this too: the detector runs includes() over the whole chunk before +// the carry scan and the fix does not touch that cost, so timing the carry alone +// reports a win the hot path cannot actually realize. These fixtures never hold +// the marker, so this mirrors the early-out branch ordinary output takes. +function detectorEarlyOut(carry, value) { + if (value.includes(GITHUB_PR_PATH_MARKER)) { + throw new Error('benchmark fixture unexpectedly contains the PR marker') + } + return carry(value) +} + +function measure(fn, chunk) { + for (let index = 0; index < WARMUP; index += 1) { + fn(chunk) + } + const samples = [] + for (let round = 0; round < 5; round += 1) { + const start = performance.now() + for (let index = 0; index < ITERATIONS; index += 1) { + fn(chunk) + } + samples.push((performance.now() - start) / ITERATIONS) + } + samples.sort((a, b) => a - b) + return samples[2] +} + +// Why non-empty fixtures: a chunk of ordinary text yields '' from both versions, +// so an equality check over it would pass even for a carry that always returns ''. +const EQUIVALENCE_FIXTURES = [ + `noise ${'x'.repeat(400)}https://github.com/acme/orca/pull/7`, + `https://github.com/acme/orca/pull/1${'x'.repeat(600)}`, + `https://github.com/acme/orca/pull/1${'x'.repeat(600)}https`, + `${'x'.repeat(1000)}https`, + `${'x'.repeat(1000)}http`, + 'https://github.com/acme/orca/pull/7 trailing words', + `${'y'.repeat(600)}`, + '', + 'https://github.com/acme/orca/pull/7' +] +for (const fixture of EQUIVALENCE_FIXTURES) { + if (carryBefore(fixture) !== carryAfter(fixture)) { + throw new Error( + `carry mismatch on fixture (len ${fixture.length}): ${JSON.stringify(carryBefore(fixture))} vs ${JSON.stringify(carryAfter(fixture))}` + ) + } +} + +const SIZES = [4 * 1024, 16 * 1024, 64 * 1024, 256 * 1024, 1024 * 1024] +const rows = [] +for (const bytes of SIZES) { + const chunk = makeChunk(bytes) + // 'with' ends in 'h', so the chunk terminates on a partial scheme fragment and + // the new code pays the extra bounded probe behind the window. + const fragmentChunk = makeChunk(bytes, 'with') + for (const sample of [chunk, fragmentChunk]) { + if (carryBefore(sample) !== carryAfter(sample)) { + throw new Error(`carry mismatch at ${bytes} bytes`) + } + } + rows.push({ + chunk: `${(bytes / 1024).toFixed(0)} KiB`, + carry: measure(carryBefore, chunk) / measure(carryAfter, chunk), + path: + measure((value) => detectorEarlyOut(carryBefore, value), chunk) / + measure((value) => detectorEarlyOut(carryAfter, value), chunk), + fragment: measure(carryBefore, fragmentChunk) / measure(carryAfter, fragmentChunk) + }) +} + +const pad = (value, width) => String(value).padStart(width) +console.log('PR-link carry scan, per PTY chunk. Speedup = before / after (>1 is faster).') +console.log(`iterations=${ITERATIONS} warmup=${WARMUP} (median of 5 rounds)`) +console.log( + `${pad('chunk', 9)} ${pad('carry only', 12)} ${pad('detector path', 15)} ${pad('fragment tail', 15)}` +) +for (const row of rows) { + console.log( + `${pad(row.chunk, 9)} ${pad(`${row.carry.toFixed(1)}x`, 12)} ${pad(`${row.path.toFixed(1)}x`, 15)} ${pad(`${row.fragment.toFixed(2)}x`, 15)}` + ) +} +console.log( + '\ncarry only = the scan this change bounds, in isolation.\n' + + 'detector path = includes() + carry, i.e. what the PTY hot path actually saves.\n' + + 'fragment tail = chunk ending mid-scheme, where the new code pays an extra\n' + + ' bounded probe. ~1x means the fallback costs nothing material.' +) diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index 23126da730f..1c16279a295 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -385,15 +385,79 @@ function verifyLocaleParity(enCatalog, localeName, localeCatalog) { } function parseArgs(argv) { - return { - fix: argv.includes('--fix') + const pluginCatalogs = [] + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (argument === '--plugin-catalog') { + const catalogPath = argv[index + 1] + if (!catalogPath || catalogPath.startsWith('--')) { + throw new Error('--plugin-catalog requires a JSON catalog path') + } + pluginCatalogs.push(catalogPath) + index += 1 + } else if (argument.startsWith('--plugin-catalog=')) { + pluginCatalogs.push(argument.slice('--plugin-catalog='.length)) + } } + return { + fix: argv.includes('--fix'), + pluginCatalogs + } +} + +async function reportPluginCatalog(root, catalog, pluginCatalogPath) { + const resolvedPath = path.resolve(root, pluginCatalogPath) + let pluginCatalog + try { + pluginCatalog = JSON.parse(await fs.readFile(resolvedPath, 'utf8')) + } catch (error) { + console.error( + `Could not read plugin catalog ${normalizePath(root, resolvedPath)}: ${error instanceof Error ? error.message : String(error)}` + ) + return 1 + } + const { enEntries, localeEntries, missingInLocale, extraInLocale, interpolationMismatches } = + collectLocaleParityIssues(catalog, pluginCatalog) + const translated = enEntries.size - missingInLocale.length - interpolationMismatches.length + const coverage = enEntries.size === 0 ? 100 : (translated / enEntries.size) * 100 + console.log( + `Plugin catalog ${normalizePath(root, resolvedPath)}: ${translated}/${enEntries.size} core keys (${coverage.toFixed(1)}% coverage), ${localeEntries.size} catalog entries.` + ) + if (missingInLocale.length > 0) { + console.log(formatMissingKeys('missing', missingInLocale.slice(0, 20))) + if (missingInLocale.length > 20) { + console.log(`...and ${missingInLocale.length - 20} more missing keys`) + } + } + if (extraInLocale.length > 0) { + console.log(formatMissingKeys('extra', extraInLocale.slice(0, 20))) + } + if (interpolationMismatches.length > 0) { + console.log(formatMissingKeys('interpolation mismatch', interpolationMismatches.slice(0, 20))) + } + // Why: absent plugin translations safely fall back to English, but a present + // value with different variables can render broken or misleading UI. + return interpolationMismatches.length > 0 ? 1 : 0 } export async function main(root = process.cwd(), options = parseArgs(process.argv.slice(2))) { const localesDir = path.join(root, LOCALES_RELATIVE_DIR) const catalogPath = path.join(localesDir, 'en.json') const catalog = JSON.parse(await fs.readFile(catalogPath, 'utf8')) + const pluginCatalogs = options.pluginCatalogs ?? [] + if (pluginCatalogs.length > 0) { + if (options.fix) { + console.error('--fix cannot be combined with --plugin-catalog') + return 1 + } + for (const pluginCatalogPath of pluginCatalogs) { + const result = await reportPluginCatalog(root, catalog, pluginCatalogPath) + if (result !== 0) { + return result + } + } + return 0 + } let catalogKeys = new Set(flattenCatalogKeys(catalog)) const sourceRoots = SOURCE_RELATIVE_ROOTS.map((sourceRoot) => path.join(root, sourceRoot)) const references = [] diff --git a/config/scripts/verify-localization-catalog.test.mjs b/config/scripts/verify-localization-catalog.test.mjs index ce148c7890f..14a41fcef66 100644 --- a/config/scripts/verify-localization-catalog.test.mjs +++ b/config/scripts/verify-localization-catalog.test.mjs @@ -2,7 +2,7 @@ import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { main as verifyLocalizationCatalog } from './verify-localization-catalog.mjs' @@ -79,4 +79,34 @@ describe('verify-localization-catalog', () => { await expect(verifyLocalizationCatalog(root, { fix: true })).resolves.toBe(1) expect(readJson(path.join(localesDir, 'en.json'))).toEqual({}) }) + + it('reports partial plugin catalog gaps but rejects malformed interpolation', async () => { + const { root } = makeProject({ + sourceText: 'export {}\n', + enCatalog: { + auto: { first: 'First {{name}}', second: 'Second' } + }, + esCatalog: { + auto: { first: 'Primero {{name}}', second: 'Segundo' } + } + }) + const pluginCatalogPath = path.join(root, 'plugin-locale.json') + writeJson(pluginCatalogPath, { + auto: { first: 'Primeiro {{wrongName}}', pluginOnly: 'Plugin only' } + }) + const report = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + try { + await expect( + verifyLocalizationCatalog(root, { + fix: false, + pluginCatalogs: [pluginCatalogPath] + }) + ).resolves.toBe(1) + expect(report).toHaveBeenCalledWith(expect.stringContaining('0/2 core keys')) + expect(report).toHaveBeenCalledWith(expect.stringContaining('interpolation mismatch')) + } finally { + report.mockRestore() + } + }) }) diff --git a/config/scripts/verify-packaged-plugin-resources.cjs b/config/scripts/verify-packaged-plugin-resources.cjs new file mode 100644 index 00000000000..701c5e73214 --- /dev/null +++ b/config/scripts/verify-packaged-plugin-resources.cjs @@ -0,0 +1,111 @@ +const { createHash } = require('node:crypto') +const { lstatSync, readFileSync, readdirSync, statSync } = require('node:fs') +const { isAbsolute, join, relative, resolve, sep } = require('node:path') + +const MAX_PLUGIN_FILES = 2_000 +const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 + +function hashLength(hash, length) { + const framedLength = Buffer.allocUnsafe(8) + framedLength.writeBigUInt64BE(BigInt(length)) + hash.update(framedLength) +} + +function hashPackagedPluginTree(root) { + const files = [] + let entriesVisited = 0 + let totalBytes = 0 + const visit = (directory) => { + const entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0 + ) + for (const entry of entries) { + if (directory === root && entry.name === '.git') { + continue + } + const entryPath = join(directory, entry.name) + const metadata = lstatSync(entryPath) + entriesVisited += 1 + if (entriesVisited > MAX_PLUGIN_FILES) { + throw new Error(`plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit`) + } + if (metadata.isSymbolicLink()) { + throw new Error(`packaged plugin contains a symlink: ${relative(root, entryPath)}`) + } + if (metadata.isDirectory()) { + visit(entryPath) + } else if (metadata.isFile()) { + totalBytes += metadata.size + if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) { + throw new Error(`plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit`) + } + files.push({ path: entryPath, size: metadata.size }) + } else { + throw new Error(`packaged plugin contains an unsupported entry: ${entryPath}`) + } + } + } + visit(root) + const hash = createHash('sha256').update('orca-plugin-tree-v1\0') + for (const file of files) { + const relativePath = relative(root, file.path).replaceAll('\\', '/') + hashLength(hash, Buffer.byteLength(relativePath, 'utf8')) + hash.update(relativePath, 'utf8') + hashLength(hash, file.size) + hash.update(readFileSync(file.path)) + } + return hash.digest('hex') +} + +function readJsonFile(path, label) { + try { + return JSON.parse(readFileSync(path, 'utf8')) + } catch (error) { + throw new Error( + `[verify-packaged-plugin-resources] invalid ${label} at ${path}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function verifyPackagedPluginResources(resourcesDir) { + const launchRoot = join(resourcesDir, 'plugins', 'launch') + if (!statSync(launchRoot).isDirectory()) { + throw new Error(`[verify-packaged-plugin-resources] missing launch directory at ${launchRoot}`) + } + const index = readJsonFile(join(launchRoot, 'bundled-plugins.json'), 'bundled plugin index') + readJsonFile(join(launchRoot, 'orca-marketplace.json'), 'marketplace index') + if (index?.version !== 1 || !Array.isArray(index.plugins) || index.plugins.length === 0) { + throw new Error('[verify-packaged-plugin-resources] bundled plugin index is empty or invalid') + } + const resolvedRoot = resolve(launchRoot) + for (const entry of index.plugins) { + if ( + typeof entry?.pluginKey !== 'string' || + typeof entry.path !== 'string' || + !/^[0-9a-f]{64}$/.test(entry.contentHash) + ) { + throw new Error('[verify-packaged-plugin-resources] bundled plugin entry is invalid') + } + const pluginRoot = resolve(launchRoot, entry.path) + const fromRoot = relative(resolvedRoot, pluginRoot) + if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error('[verify-packaged-plugin-resources] bundled plugin path escapes launch root') + } + const manifest = readJsonFile(join(pluginRoot, 'orca-plugin.json'), 'plugin manifest') + if (`${manifest.publisher}.${manifest.id}` !== entry.pluginKey) { + throw new Error( + `[verify-packaged-plugin-resources] manifest identity does not match ${entry.pluginKey}` + ) + } + if (hashPackagedPluginTree(pluginRoot) !== entry.contentHash) { + throw new Error( + `[verify-packaged-plugin-resources] packaged bytes do not match ${entry.pluginKey}` + ) + } + } + console.log( + `[verify-packaged-plugin-resources] OK — verified ${index.plugins.length} bundled plugin(s)` + ) +} + +module.exports = { verifyPackagedPluginResources } diff --git a/config/scripts/verify-packaged-plugin-resources.test.mjs b/config/scripts/verify-packaged-plugin-resources.test.mjs new file mode 100644 index 00000000000..79dda1f4866 --- /dev/null +++ b/config/scripts/verify-packaged-plugin-resources.test.mjs @@ -0,0 +1,77 @@ +import { cp, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { verifyPackagedPluginResources } = require('./verify-packaged-plugin-resources.cjs') + +describe('verify packaged plugin resources', () => { + it('accepts exact launch bytes copied into a packaged resources directory', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) + try { + await cp( + join(process.cwd(), 'resources', 'plugins', 'launch'), + join(resourcesDir, 'plugins', 'launch'), + { recursive: true } + ) + + expect(() => verifyPackagedPluginResources(resourcesDir)).not.toThrow() + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + it('rejects mutated bytes in the packaged output', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) + try { + const launchRoot = join(resourcesDir, 'plugins', 'launch') + await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { + recursive: true + }) + await writeFile( + join(launchRoot, 'stablyai.orca-navigation-shortcuts', 'extra.json'), + '{"mutated":true}\n' + ) + + expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow( + 'packaged bytes do not match stablyai.orca-navigation-shortcuts' + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) + + // The tree is hashed by raw bytes, so a CRLF checkout on Windows breaks the + // pinned hash. These two guard the `.gitattributes` eol=lf pin that prevents it. + it('pins the launch tree to LF so Windows checkouts hash identically', async () => { + const attributes = await readFile(join(process.cwd(), '.gitattributes'), 'utf8') + expect(attributes).toContain('/resources/plugins/** text eol=lf') + }) + + it('rejects a CRLF checkout of the launch tree', async () => { + const resourcesDir = await mkdtemp(join(tmpdir(), 'orca-packaged-plugins-')) + try { + const launchRoot = join(resourcesDir, 'plugins', 'launch') + await cp(join(process.cwd(), 'resources', 'plugins', 'launch'), launchRoot, { + recursive: true + }) + for (const entry of await readdir(launchRoot, { recursive: true })) { + const path = join(launchRoot, entry) + if (!(await stat(path)).isFile()) { + continue + } + await writeFile(path, (await readFile(path, 'utf8')).replace(/\r?\n/g, '\r\n')) + } + + // Every file is rewritten, so the first mismatch is whichever plugin sorts + // first — don't pin a name a later branch can reorder. + expect(() => verifyPackagedPluginResources(resourcesDir)).toThrow( + /packaged bytes do not match stablyai\./ + ) + } finally { + await rm(resourcesDir, { recursive: true, force: true }) + } + }) +}) diff --git a/config/scripts/windows-signing-gate-toolset.test.mjs b/config/scripts/windows-signing-gate-toolset.test.mjs new file mode 100644 index 00000000000..8e83534421e --- /dev/null +++ b/config/scripts/windows-signing-gate-toolset.test.mjs @@ -0,0 +1,306 @@ +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Why: the inner-binary signing gate silently degraded for four releases because it +// shelled out to a hardcoded `node_modules/7zip-bin/...` path that electron-builder +// 26.9+ no longer installs. Pin both workflows to the resolver instead (#6487). + +const workflowsDir = resolve(import.meta.dirname, '../..', '.github', 'workflows') + +const GATED_WORKFLOWS = ['release-cut.yml', 'windows-signing-rehearsal.yml'] + +function workflowSource(name) { + return readFileSync(join(workflowsDir, name), 'utf8') +} + +// Why a scanner and not a regex: PowerShell gates here contain braces inside +// strings (`"{0,-14} {1} <{2}>" -f ...`) and inside comments, so naive brace +// counting mis-pairs and every scope assertion below silently degrades into +// "some text appears somewhere in the file". The same walk also lets assertions +// distinguish a keyword the shell executes from the same word sitting in a +// message string — downgrading `throw` to `Write-Host "...would throw..."` +// otherwise passes a `/\bthrow\b/` check while restoring the silent fail-open. + +/** `source` split into `{start, end, kind}` spans, kind being 'code' | 'string' | 'comment'. */ +function scanSpans(source) { + // Here-strings use different terminator rules; refusing them beats mis-pairing silently. + expect(source, 'here-strings are not understood by this scanner').not.toMatch(/@['"]/) + const spans = [] + let i = 0 + while (i < source.length) { + const char = source[i] + if (char === '#') { + const newline = source.indexOf('\n', i) + const end = newline === -1 ? source.length : newline + spans.push({ start: i, end, kind: 'comment' }) + i = end + continue + } + if (char === "'") { + let end = i + 1 + while (end < source.length) { + if (source[end] !== "'") { + end += 1 + } else if (source[end + 1] === "'") { + end += 2 // doubled '' escapes a quote rather than closing the string + } else { + break + } + } + end = Math.min(end + 1, source.length) + spans.push({ start: i, end, kind: 'string' }) + i = end + continue + } + if (char === '"') { + let end = i + 1 + while (end < source.length && source[end] !== '"') { + end += source[end] === '`' ? 2 : 1 + } + end = Math.min(end + 1, source.length) + spans.push({ start: i, end, kind: 'string' }) + i = end + continue + } + let end = i + while (end < source.length && !'#\'"'.includes(source[end])) { + end += 1 + } + spans.push({ start: i, end, kind: 'code' }) + i = end + } + return spans +} + +/** `source` with the named span kinds blanked to spaces — same length, so indices still line up. */ +function blank(source, spans, kinds) { + const chars = source.split('') + for (const span of spans) { + if (!kinds.includes(span.kind)) { + continue + } + for (let i = span.start; i < span.end; i += 1) { + if (chars[i] !== '\n') { + chars[i] = ' ' + } + } + } + return chars.join('') +} + +/** `source` with comments blanked — for assertions whose subject is a literal the gate prints. */ +function withoutComments(source) { + return blank(source, scanSpans(source), ['comment']) +} + +/** `source` with strings and comments blanked — for assertions about executed statements. */ +function codeOf(source) { + return blank(source, scanSpans(source), ['string', 'comment']) +} + +/** + * The `{ ... }` block opening after `marker`. `code` has strings and comments blanked, so a + * keyword assertion against it can only be satisfied by a keyword the shell would execute; + * `text` keeps strings but drops comments, for assertions about literals the gate emits. + */ +function blockAfter(source, marker, from = 0) { + const spans = scanSpans(source) + const code = blank(source, spans, ['string', 'comment']) + const markerIndex = code.indexOf(marker, from) + expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1) + const start = code.indexOf('{', markerIndex) + expect(start, `no block opens after: ${marker}`).toBeGreaterThan(-1) + let depth = 0 + let end = -1 + for (let i = start; i < source.length && end === -1; i += 1) { + if (code[i] === '{') { + depth += 1 + } else if (code[i] === '}') { + depth -= 1 + if (depth === 0) { + end = i + } + } + } + expect(end, `unbalanced block after: ${marker}`).toBeGreaterThan(-1) + return { + start, + end, + code: code.slice(start, end + 1), + text: blank(source, spans, ['comment']).slice(start, end + 1) + } +} + +/** + * The innermost `{ ... }` block enclosing `marker`, plus the keyword introducing it. + * Needed where the anchor is the block's *contents*: `blockAfter(step, '} catch {')` picks + * whichever catch comes first in the file, which stopped being the gate's own once the + * persistence helpers grew their own try/catch. + */ +function blockEnclosing(source, marker) { + const spans = scanSpans(source) + const code = blank(source, spans, ['string', 'comment']) + // Located in the comment-stripped text (the marker may include a string literal), + // then paired in `code`; both blankings preserve length, so indices line up. + const markerIndex = blank(source, spans, ['comment']).indexOf(marker) + expect(markerIndex, `missing marker: ${marker}`).toBeGreaterThan(-1) + let depth = 0 + let end = -1 + for (let i = markerIndex; i < code.length && end === -1; i += 1) { + if (code[i] === '{') { + depth += 1 + } else if (code[i] === '}') { + if (depth === 0) { + end = i + } else { + depth -= 1 + } + } + } + depth = 0 + let start = -1 + for (let i = markerIndex; i >= 0 && start === -1; i -= 1) { + if (code[i] === '}') { + depth += 1 + } else if (code[i] === '{') { + if (depth === 0) { + start = i + } else { + depth -= 1 + } + } + } + expect(start, `no block encloses: ${marker}`).toBeGreaterThan(-1) + expect(end, `no block encloses: ${marker}`).toBeGreaterThan(-1) + return { start, end, keyword: code.slice(0, start).trimEnd().split(/\s+/).pop() } +} + +describe('Windows signing gates resolve 7za through the toolset resolver (#6487)', () => { + for (const name of GATED_WORKFLOWS) { + it(`${name} does not hardcode the removed 7zip-bin path`, () => { + expect(workflowSource(name)).not.toContain('node_modules/7zip-bin') + }) + + it(`${name} resolves 7za via resolve-7za-path.mjs`, () => { + expect(workflowSource(name)).toContain('node config/scripts/resolve-7za-path.mjs') + }) + + it(`${name} checks resolver failure before trimming its output`, () => { + const source = workflowSource(name) + const code = codeOf(source) + const resolveIndex = code.indexOf('$7zaOutput = node config/scripts/resolve-7za-path.mjs') + const exitCodeIndex = code.indexOf('$7zaExitCode = $LASTEXITCODE') + const exitGuard = blockAfter(source, 'if ($7zaExitCode -ne 0)') + const trimIndex = code.indexOf('$7za = ($7zaOutput | Out-String).Trim()') + + expect(resolveIndex).toBeGreaterThan(-1) + expect(exitCodeIndex).toBeGreaterThan(resolveIndex) + expect(exitGuard.start).toBeGreaterThan(exitCodeIndex) + expect(exitGuard.code).toMatch(/\bthrow\b/) + expect(trimIndex).toBeGreaterThan(exitGuard.end) + }) + + it(`${name} rejects an empty or non-file 7za path`, () => { + const source = workflowSource(name) + const guard = blockAfter( + source, + 'if ([string]::IsNullOrWhiteSpace($7za) -or -not (Test-Path -LiteralPath $7za -PathType Leaf))' + ) + expect(guard.code).toMatch(/\bthrow\b/) + }) + } + + // Why sliced to one step: release-cut.yml runs several PowerShell gates that + // share idioms (`$failures`, `} catch {`), so a whole-file search silently + // asserts against the wrong block. + function innerBinaryStep() { + const source = workflowSource('release-cut.yml') + const start = source.indexOf('- name: Verify Windows inner binary signatures') + expect(start).toBeGreaterThan(-1) + const end = source.indexOf('\n - name:', start + 1) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) + } + + // Why parse the function body rather than grep the file: asserting that the + // string 'Write-GateVerdict' appears somewhere passes even if the body is + // gutted to a Write-Host, which is exactly the silent degradation this gate + // exists to prevent. + function gateVerdictBlock() { + return blockAfter(innerBinaryStep(), 'function Write-GateVerdict') + } + + it('persists the verdict to the evidence file the artifact upload collects', () => { + const block = gateVerdictBlock() + expect(block.text).toMatch(/Set-Content\s+-Path\s+'inner-signing-evidence\.txt'/) + expect(block.code).toContain('Add-GateSummary') + }) + + // Why cross-checked: the upload is `if-no-files-found: ignore`, so renaming the + // evidence file on one side and not the other ships a green run with an artifact + // that silently omits the verdict — the same class as the bug this PR fixes. + it('uploads the exact evidence filename the gate writes', () => { + const source = workflowSource('release-cut.yml') + const uploadStart = source.indexOf('- name: Upload Windows inner signing evidence') + expect(uploadStart).toBeGreaterThan(-1) + const uploadEnd = source.indexOf('\n - name:', uploadStart + 1) + expect(uploadEnd).toBeGreaterThan(uploadStart) + const upload = source.slice(uploadStart, uploadEnd) + + const step = innerBinaryStep() + const written = new Set( + [...withoutComments(step).matchAll(/-Path\s+'([\w.-]+\.txt)'/g)].map((m) => m[1]) + ) + expect(written.size).toBeGreaterThan(0) + for (const file of written) { + expect(upload, `${file} is written by the gate but never uploaded`).toContain(file) + } + }) + + it('never lets verdict persistence itself fail a warn-only release', () => { + // Every persistence helper is best-effort: a disk-full or read-only runner + // must not turn evidence-writing into the thing that fails the release. + const step = innerBinaryStep() + for (const helper of ['function Add-GateEvidence', 'function Add-GateSummary']) { + const code = blockAfter(step, helper).code + expect(code, helper).toContain('-ErrorAction Stop') + expect(code, helper).toMatch(/\bcatch\b/) + } + expect(gateVerdictBlock().code).toMatch(/\bcatch\b/) + }) + + it('records a verdict on every terminal branch of the gate', () => { + // Comments stripped: a `# VERDICT: PASSED` note must not stand in for the write. + const step = withoutComments(innerBinaryStep()) + for (const verdict of ['NOT VERIFIED', 'ERRORED', 'VERDICT: FAILED', 'VERDICT: PASSED']) { + expect(step).toContain(verdict) + } + }) + + it('throws a required-mode signature failure outside the catch that would mask it', () => { + // Why: throwing inside `try` re-enters the catch, whose Set-Content + // replaces the per-file report with "ERRORED — ". + const step = innerBinaryStep() + const policyThrow = codeOf(step).indexOf('if ($policyFailure) { throw $policyFailure }') + expect(policyThrow).toBeGreaterThan(-1) + // Anchored on the gate's own handler, not the first `catch` in the step: the + // persistence helpers have their own, and they sit earlier in the file. + const gateCatch = blockEnclosing(step, 'Write-GateVerdict "ERRORED') + expect(gateCatch.keyword).toBe('catch') + expect(policyThrow).toBeGreaterThan(gateCatch.end) + }) + + // Why: the assignment is what survives a write failure. With it after the + // evidence/summary writes, a throwing Add-Content lands in the catch with + // $policyFailure still null — required mode reports ERRORED and overwrites the + // per-file report, reintroducing exactly the loss the hoist prevents. + it('records the required-mode failure before attempting any evidence write', () => { + const branch = blockAfter(innerBinaryStep(), 'if ($failures.Count -gt 0)').code + const assignment = branch.indexOf('$policyFailure = $message') + expect(assignment).toBeGreaterThan(-1) + for (const write of ['Add-GateEvidence', 'Add-GateSummary']) { + expect(branch.indexOf(write), write).toBeGreaterThan(assignment) + } + }) +}) diff --git a/docs/assets/readme-downloads.svg b/docs/assets/readme-downloads.svg index 72eea780cf3..559da5e20be 100644 --- a/docs/assets/readme-downloads.svg +++ b/docs/assets/readme-downloads.svg @@ -1,5 +1,5 @@ - - downloads: 10m + + downloads: 11m @@ -15,7 +15,7 @@ downloads downloads - 10m - 10m + 11m + 11m diff --git a/docs/orchestration-primitives.html b/docs/orchestration-primitives.html new file mode 100644 index 00000000000..00884666035 --- /dev/null +++ b/docs/orchestration-primitives.html @@ -0,0 +1,2937 @@ + + + + + + + Orca Orchestration: Strong Primitives, Little Magic + + + + +
+ + +
+
+
+

Orca orchestration proposal

+

Strong primitives. Little magic. No orchestration product inside the product.

+

+ Orca should make it intuitive for a coordinating agent to start workers, communicate, + wait, observe output, and recover safely. Orca supplies dependable building blocks; + the agent decides the orchestration strategy. +

+ +
+ CLI and runtime only + agent-directed + multi-server capable + explicit effects + no commit tracking +
+ +
+ +
+ The common agent loop +

+ Create and bind one Run for the coordination effort; after that its ID is carried + automatically. Create tasks, start workers on this or another connected Orca + server, send messages, wait for inbox mail, and read or stop workers. Options + refine those operations without adding policy. +

+
+
+ +

+ The test for the design is simple: after reading a few examples, an agent should be + able to predict what every command creates, reuses, blocks on, and returns. +

+
+ +
+
+

What we learned

+

The current problems are coordination problems, not missing product surfaces.

+

+ The research found real reliability gaps, but the earlier proposal responded by + adding a scheduler, integration subsystem, dashboard, and large control-plane model. + Those additions would make the common agent workflow harder to understand. +

+
+ +
+
+

Current

+

Lifecycle mail arrives through prompt injection

+

+ Messages persist, but coordinator delivery may wait for the agent to pause. If it + keeps polling or working, messages can collect and flood the editable input after + a manual interruption. +

+
+ +
+

Needed

+

A structured, blocking inbox call

+

+ Typed worker lifecycle messages return from a pending tool request or the next explicit + read. Routine lifecycle delivery never writes into the coordinator's prompt. +

+
+
+ +
+
+

Current

+

Starting a worker is assembled from low-level pieces

+

+ Creating a worktree may already create a terminal, but an agent can miss that and + create another terminal before launching the worker. +

+
+ +
+

Needed

+

One composed start operation with full topology and setup choices

+

+ The operation composes existing worktree, setup, terminal, and agent creation and + returns exactly which resources it created or reused. +

+
+
+ +
+
+

Current

+

Terminal scrollback is treated as agent history

+

+ Full-screen TUIs can redraw or discard the useful conversation, making terminal + reads incomplete or misleading. +

+
+ +
+

Implemented

+

Exact structured output with a truthful fallback

+

+ Orca reuses its pane-scoped hook association to read a supported Codex, Claude, + OpenClaude, or Grok transcript. When it cannot prove that source, it returns + labeled bounded terminal output instead of guessing a session. +

+
+
+ +
+
+

Current

+

Results are mostly worker assertions

+

+ Orca verifies that the active dispatch reported worker_done, but the + summary, changed files, tests, and report path come from the worker. +

+
+ +
+

Needed

+

Be explicit about what Orca observed

+

+ Keep lifecycle authority separate from worker-reported content. Do not add commit, + test, merge, or integration tracking merely to make the report appear stronger. +

+
+
+
+ +
+
+

Design rules

+

Reliability should live underneath a small interface.

+

+ Strong primitives have narrow, testable contracts. They do not need to expose every + mechanism used to make the contract safe. +

+
+ +
+
+ 01 +

The coordinator owns strategy

+

+ The agent chooses decomposition, ordering, parallelism, placement, review, and when + to wait. Orca does not schedule ready tasks automatically. +

+
+
+ 02 +

Every effect is visible

+

+ Responses say which connected server, worktree, setup, terminal, execution host, + and agent were created or reused. Defaults are reported, not hidden. +

+
+
+ 03 +

Simple default, explicit escape hatch

+

+ The common path needs few arguments and preserves worktree/setup choices. Uncommon + custom launches stay on the existing low-level commands instead of bloating start. +

+
+
+ 04 +

Observation is honest

+

+ Orca distinguishes observed process state from worker-reported claims and labels + the source of transcript or terminal output. +

+
+
+ 05 +

Remote ambiguity stays ambiguous

+

+ A disconnect after a remote mutation returns outcome_unknown. Orca + does not silently repeat a command that may have succeeded. +

+
+
+ 06 +

Safety is not orchestration policy

+

+ Stable identity, stale-worker fencing, and owner routing prevent corruption. They + do not choose what work should happen next. +

+
+
+ +

+ A feature belongs in the core only if it makes an existing primitive safer or clearer. + Tracking extra domain facts—commits, merges, budgets, priorities, or organizational + roles—is not automatically a stronger primitive. +

+
+ +
+
+

Primitive 1 · Scope and identity

+

Four public concepts, each with one job.

+

+ A Run prevents unrelated coordination efforts from mixing. Tasks describe work, + Dispatches authorize workers, and Messages communicate. Existing worktree and + terminal resources remain independently usable. +

+
+ +
+
+

Run

+

A lightweight namespace and stable coordinator mailbox. It never schedules work.

+
+
+

Task

+

A durable description, status, and optional dependencies. Creating it starts nothing.

+
+
+

Dispatch

+

One supervised worker assignment and its current lifecycle authority.

+
+
+

Message

+

Durable communication or a typed lifecycle report returned through the inbox.

+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Persisted worker stateTask state and allowed next action
starting / readyTask is dispatched; only show, read, message, or stop may act on it.
start_unknownTask is blocked; recover the same request receipt, inspect, stop, or abandon.
failed + Confirmed start failure and authenticated worker failure both leave Task + failed. Either permits an explicit --retry-of replacement. +
succeededTask is completed; create a follow-up Task rather than retrying it.
stopping / stop_unknown + Task is blocked and lifecycle authority is fenced; inspect termination or + explicitly abandon before replacement. +
stopped / abandonedTask is blocked and permits an explicit --retry-of replacement.
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Run operationExact effect
run-create + Creates a run and stable coordinator mailbox on the coordinator's selected + Orca server—its Run home—then binds the current coordinator terminal as its + active consumer. +
run-use --id run_123 + Explicitly binds or rebinds the current terminal to that run. Rebinding fences + the prior consumer generation and cancels its waiter. A terminal has at most + one active run binding. +
run-current / run-list / run-showReports bindings and state without changing tasks, workers, or mail.
+
+ +
+
+

Run IDs stay out of the common path

+

+ A command resolves the run only from explicit --run or the current + terminal binding—never from a worktree or an “exactly one candidate” guess. + Commands return the resolved Run or runId where it is needed for later + control; agents do not carry a separate resolution-mode field. +

+
+
+

Low-level commands remain run-free

+

+ Ordinary worktree, terminal, and full-handoff commands do not create or require a + Run. Runs exist only for supervised coordination that needs durable grouping. +

+
+
+

One home, workers anywhere

+

+ A Run has one home server that owns its tasks and inbox. A Dispatch may point to a + worker on any connected Orca server; Orca relays that worker's messages back to the + home automatically. +

+
+
+ +

+ ELI5: a Run is a folder label plus a return address. It keeps one coordination + effort's tasks and mail together and gives workers on your Mac or Windows server the + same stable place to reply. + Create or select it once, then ordinary orchestration commands inherit it. It does + not create resources, choose workers, schedule tasks, or group projects. +

+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Canonical commandCompatibility decision
run-create/list/show/use/currentNew lightweight scope commands. They never decompose or schedule tasks.
task-create/list/updateKeep the current flat command names and add Run association.
dispatchKeep as the low-level binding to an exact existing terminal.
worker-start/show/read/stop/abandonNew composed supervised-worker operations.
send/ask/reply/checkKeep and strengthen the current message operations.
Current scheduler-like orchestration run --spec + Must be removed or renamed in Phase 0 before run-* can ship. It is + not an alias for a lightweight Run and is not part of this scheduler-free + design. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDWho normally carries it
runIdThe terminal binding carries it; agents pass it only to override context.
taskIdThe coordinator uses it for dependencies and worker start.
dispatchId + Receipts and worker preambles carry it; show, read, stop, abandon, retry, and + lifecycle reports use it. +
deliveryIdThe current mailbox consumer carries only its last unacknowledged delivery.
messageId / threadId + reply takes a message ID and infers its thread. Agents do not + manage a separate question identifier. +
Resource IDsReceipt data until an agent explicitly reads, stops, or reuses that resource.
RPC request ID + The client transport creates it automatically. Agents only echo the returned + retry token after an unknown outcome; they never invent one. +
+
+ +
orca orchestration run-create --objective "Improve message delivery"
+# → run_123, bound to this coordinator terminal
+orca orchestration task-create --spec "Audit message delivery"
+# → task_a, runId run_123 (bound)
+orca orchestration worker-start --task task_a --worktree current --agent codex
+orca orchestration check --wait --timeout-ms 60000
+
+ +
+
+

Primitive 2 · Supervised worker start

+

One request, predictable behavior for every topology.

+

+ worker-start is one synchronous composition of existing worktree, setup, + terminal, and dispatch operations. It is not a background executor, external + transaction, or placement engine. The coordinator chooses the topology; Orca returns + only after the composition is ready, failed, or honestly unknown. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TopologyDefault terminal behaviorSetup behavior
Current worktree + Create one fresh agent terminal. Never reuse the coordinator terminal or an + arbitrary idle terminal. Reuse requires explicit --terminal. + not_applicable; do not rerun setup or configured tabs.
Named existing worktreeCreate one fresh agent terminal; reuse only an explicitly selected terminal.not_applicable; creation-time setup is not replayed.
New child worktree + Use agent-first worktree creation and reuse its returned agent terminal. Never + create a second shell/agent terminal. + + Default to run. Setup and agent launch start side by side unless the + repository explicitly uses wait-for-setup. skip or + inherit must be explicit. +
New top-level worktreeSame agent-first behavior, with top-level Orca lineage. + Same setup default: run a configured hook; use an explicit escape hatch only + for a concrete reason. +
+
+ +
+
+

Worktree option parity means pass-through

+

+ The exact repository selector, name, base branch, child/top-level lineage, setup, + and display/comment metadata are validated and passed to the existing worktree + primitive. --on already chooses the connected Orca server, so V1 does + not add a second project/host placement vocabulary to worker-start; + agents may use low-level worktree create when that convenience selector + is important. +

+ +
+
+

Choose a connected server only when needed

+

+ The default is the Run home. Use --on windows (a saved Orca environment + name or ID) only to place a worker on another connected server. V1 resource IDs + are server-scoped, so every remote existing worktree or terminal also requires + --on. Orca never guesses an owner from a same-looking ID and echoes the + resolved server name in the receipt. +

+
+
+

Agent selection is honest

+

+ When creating a terminal, V1 requires an explicit --agent that resolves + through Orca's configured launcher before any effect. Composed start does not + promise custom model, environment, or arbitrary command arguments that agent-first + worktree creation cannot actually pass through. +

+
+
+

Setup is the safe default

+

+ For every new worktree, omitted --setup resolves to + run. If a setup hook exists, Orca launches it; if none exists, the + receipt says not_configured. Preserve the repository's existing + setupAgentStartupPolicy: its default is start-immediately, + so setup does not delay agent launch or task delivery. Only an explicit + wait-for-setup policy gates the agent. An agent may choose + skip or inherit only for a specific reason it states in its + work log. Orca trusts that judgment and adds no approval gate. +

+
+
+

Supervised means lifecycle injection

+

+ Task and dispatch input is delivered only after agent readiness. Ordinary + worktree/terminal commands remain the full-handoff path without lifecycle duties. +

+
+
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Placement/federation errorMeaning
server_requiredThe selected runtime cannot provide connected-server orchestration.
worktree_not_found_on_serverThe exact worktree does not exist on the selected worker server.
terminal_worktree_mismatchThe exact terminal is not owned by the selected worktree.
resource_server_mismatch + A worker-server receipt names a different Dispatch or authenticated Run home; + Orca never adopts that attachment. +
agent_unconfiguredThe requested launcher is unavailable; no worktree or terminal was created.
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
Task acceptanceWorker-start effect
First start + Requires a ready Task with no current Dispatch. The runtime creates the + Dispatch and moves the Task to dispatched in one local transaction before + running the composed effects. +
Replacement attempt + Requires --retry-of naming the Task's current failed, stopped, or + abandoned Dispatch while the Task is failed or blocked. Start creates the next + Dispatch generation and moves the Task back to dispatched atomically; the agent + never performs a preparatory task-update. +
Anything else + Return task_not_startable naming the rejected Task/Dispatch and + perform no worker effects. Completed work gets a new follow-up Task; an unknown + or nonterminal Dispatch must first be inspected, stopped, or abandoned. +
+
+ +

+ ready has one testable meaning: the selected agent terminal reached + tui-idle, the local or remote Dispatch attachment is durable, and the + lifecycle preamble plus task input was accepted. Under the default + start-immediately policy, setup may still be running and its exact state is + returned in the receipt; its outcome never gates readiness, even if failure is observed + before the agent reaches tui-idle. Under an explicit + wait-for-setup repository policy, setup must complete successfully before + agent launch and task injection. Agent-first worktree creation launches without the + task prompt so Orca can establish authority before injection. The effective timeout + and startup policy are echoed in every receipt. + A successful gated receipt reports setup succeeded. A confirmed setup + spawn/script failure reports failed before task input, while a timeout may + honestly retain running rather than inventing a failure. +

+ + + + + +
+
orca orchestration worker-start --task task_a --worktree new-child --name message-audit --agent codex --setup run
+
+# Returns when the worker is ready, start failed, or the outcome is unknown.
+# Coordinators can issue independent start calls in parallel.
+
# "result" excerpt from --json
+{
+  "runId": "run_123",
+  "taskId": "task_a",
+  "dispatchId": "dispatch_7",
+  "state": "ready",
+  "stage": "input_accepted",
+  "setup": {
+    "requested": "run",
+    "effective": "run",
+    "source": "explicit_request",
+    "hookFound": true,
+    "startupPolicy": "start-immediately",
+    "state": "running"
+  },
+  "timeoutMs": 60000,
+  "effects": [
+    { "kind": "worktree", "action": "created_child", "id": "worktree_9" },
+    { "kind": "terminal", "role": "setup", "action": "created", "id": "term_setup_11", "tabId": "tab_2", "leafId": "leaf_1" },
+    { "kind": "setup", "action": "run", "requested": "run", "effective": "run", "source": "explicit_request", "hookFound": true, "startupPolicy": "start-immediately", "state": "running", "terminalId": "term_setup_11" },
+    { "kind": "terminal", "role": "agent", "action": "reused_agent_terminal", "id": "term_12" },
+    { "kind": "terminal", "role": "configured_tab", "action": "created", "id": "term_13", "tabId": "tab_3", "leafId": "leaf_1" },
+    { "kind": "dispatch_input", "role": "agent", "id": "term_12", "state": "accepted" }
+  ],
+  "residualResources": [],
+  "mutation": { "requestId": "req_7", "replayed": false }
+}
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + +
Start stateMeaning and next action
ready + Agent is ready and lifecycle input was accepted. The return receipt contains + every created or reused effect; no separate startup notice is required. +
failed + Return the failed stage, last error, residual resources, Task/Dispatch state, + and the durable mutation receipt. The composition itself has returned, but any + surviving setup, terminal, or agent process is listed honestly as a residual + resource; Orca never implies that failure cleaned it up. The coordinator + chooses the matching recovery branch below. +
outcome_unknown + The connection failed after an effect may have happened. Return the operation + stage, mutation.requestId, durable effects/residuals, and exact + worker-show/worker-abandon commands. A replacement is + rejected until inspection proves it safe or the coordinator explicitly + abandons the old Dispatch. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Unknown-start recoveryExact contract
Query and reconcile + worker-show --dispatch dispatch_7 routes from the Run home to the + owning worker server and execution host. If its durable receipt proves the + original worker became ready, failed, or stopped, the home reconciles that same + Dispatch; no separate adopt operation exists. +
Safe retry + --retry-of dispatch_7 links a new attempt but does not silently + reuse prior placement. The coordinator repeats an explicit valid topology and + agent/terminal choice, which may deliberately differ from the old attempt. A + new Dispatch is allowed only after the prior one is failed, stopped, abandoned, + or reconciled as no-effect. While it remains unknown, return + task_not_startable without mutation. +
Explicit stop + worker-stop --dispatch dispatch_7 may fence a + ready or start_unknown Dispatch. The owning server + closes a terminal only when its durable attachment still matches the exact + pane and process incarnation. Unattached, missing, exited, or identity-changed + workers become stop_unknown with no process action. If the remote + server durably stopped the exact worker but its response was lost, a later + worker-show reconciles that authoritative stopped receipt. +
Explicit abandon + worker-abandon --dispatch dispatch_7 fences future lifecycle + mutations from that Dispatch and records every possibly-live resource. It sends + no remote command, claims no process stopped, deletes nothing, and warns that a + concurrent worker may remain. The coordinator may then start a replacement. + Abandoning an older superseded Dispatch is a no-op and cannot block or rewrite + the replacement Task. +
+
+ +

+ The Dispatch ID is the worker identity. Do not add another agent-facing start ID. + Every mutating CLI result includes one opaque mutation.requestId for + recovering that exact request after a lost response; agents never invent it. A + transport failure exposes the same value as orchestrationRequestId in its + error recovery data. Semantic retry + explicitly names the prior Dispatch ID and never replays an unknown effect. After a + remote start, show, read, stop, abandon, and message routing use the Dispatch receipt; + the agent does not repeat --on for those controls or carry server IDs. A + replacement worker-start is a new placement decision and names its target + again. +

+ + +
+ +
+
+

Primitives 3 and 4 · Message and wait

+

Structured mail is inbox-only, durable, and explicitly consumed.

+

+ The inbox is an agent API, not product UI and not terminal input. It strengthens + Orca's existing message store and check --wait; it does not add an Event + subsystem or a second orchestration engine. +

+
+ +

+ send, ask, reply, completion, heartbeat, and runtime + notices only persist structured state and wake a pending tool waiter. Only explicit + dispatch --inject and terminal send may modify terminal input. + From an active Dispatch, lifecycle send and ask default to that + Dispatch's owning Run mailbox. “Sent” means durably accepted—not pasted, observed, or + acted upon. +

+ +
+
+

run:run_123

+

The stable coordinator mailbox stored with that Run in the Orca runtime.

+
+
+

dispatch:dispatch_7

+

The exact supervised worker generation, independent of terminal handle changes.

+
+
+ + + +

+ Orca authenticates lifecycle reports automatically. The worker supplies only the Task + and Dispatch IDs injected in its preamble; it never supplies Run, runtime, host, + terminal, capability, or attestation IDs. At injection, Orca gives the managed pane a + narrow unforgeable Dispatch capability through its CLI bridge. The worker server + verifies that capability and its local pane; the Run home accepts the relayed report + only from the pinned paired peer for that Dispatch. Stop, abandon, or replacement + revokes it. Other reports remain stale history and cannot change Task state. This is + lifecycle integrity, not a general permission system. +

+ + + +
+
+

One Run home

+

+ The coordinator's server stores the authoritative Run, tasks, inbox ordering, and + acknowledgments. Worker servers do not replicate the Run database or elect a new + home. +

+
+
+

Remote mail waits safely

+

+ A worker server durably retains messages for its remote Dispatch until the Run + home imports and acknowledges them. Replies wait at the home until the worker + server reconnects. This is a narrow relay queue, not a replicated global inbox. +

+
+
+ +
+
+
Coordinator
+
Run home ↔ worker server
+
Worker
+
+
+
Starts every independent worker
+
Persists home record and remote attachment
+
Works concurrently
+
+
+
Calls check --wait only when no local work remains
+
Returns the outstanding batch or registers one waiter
+
Continues independently
+
+
+
Pending tool call is blocked
+
Durably relays question, failure, or completion
+
Reports one typed lifecycle message
+
+
+
Receives a structured batch
+
Persists and returns one opaque Delivery ID
+
No coordinator prompt injection
+
+
+
Processes every message, then acknowledges and waits
+
Atomically ack → check → register
+
May continue, stop, or receive a reply
+
+
+ +

+ check --wait always targets the Run home and fans in mail from every active + remote Dispatch. One disconnected worker server does not block local or other-server + messages. Imported relay items are idempotent by their authenticated Dispatch and + source sequence, then follow the same FIFO delivery and acknowledgment rules as local + mail. +

+ +
+
orca orchestration check --wait --timeout-ms 60000
+# → returns delivery_81 with an ordered message batch
+
+# Process every message and start newly-ready work.
+orca orchestration check --ack delivery_81 --wait --timeout-ms 60000
+
# "result" excerpt from --json
+{
+  "runId": "run_123",
+  "deliveryId": "delivery_81",
+  "messages": [
+    {
+      "id": "msg_481",
+      "run_id": "run_123",
+      "from_handle": "dispatch:dispatch_7",
+      "to_handle": "run:run_123",
+      "subject": "Review complete",
+      "type": "worker_done",
+      "payload": "{\"taskId\":\"task_a\",\"dispatchId\":\"dispatch_7\",\"outcome\":\"succeeded\"}",
+      "read": 0
+    }
+  ],
+  "count": 1,
+  "replayed": false,
+  "acknowledged": null,
+  "timedOut": false,
+  "cancelled": false,
+  "connectionLost": false
+}
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Inbox ruleV1 contract
Ordering and sizeFIFO by mailbox sequence, bounded to 50 messages per delivery.
Outstanding batch + One per mailbox. Until acknowledged, return the same Delivery ID and batch; + newer mail waits behind it. +
Acknowledgment + Whole-batch and idempotent. Repeating an acknowledged Delivery ID returns the + recorded result and does not consume newer mail. A Delivery is bound to the + consumer generation that received it; a fenced coordinator gets + consumer_fenced and cannot consume the replacement's mail. The same + current-consumer check applies to coordinator replies and other mailbox + mutations. +
Waiters + One active actionable waiter per mailbox. A second returns + waiter_exists; it never races to consume the batch. +
Crash safety + Receiving a batch never marks it consumed. Unacknowledged mail survives client, + mailbox-consumer, and runtime restart. +
Atomic continuation + check --ack delivery_81 --wait commits ack, checks queued mail, + then registers the waiter as one runtime operation. +
+
+ +
+
+

History modes do not consume

+

+ check --peek, --all, and type-filtered history reads are + read-only debugging surfaces. Legacy consume-on-check behavior is deprecated. + Rename local formatting flag check --inject so it cannot imply delivery. +

+
+
+

Timeouts are typed checkpoints

+

+ Wait returns timedOut, cancelled, or + connectionLost distinctly. None means worker failure, none consumes + mail, and transport keepalive output is not a worker heartbeat. +

+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Lifecycle inputAtomic state effect at message acceptance
Authenticated active Dispatch reports worker_done outcome=succeededSet Dispatch settled/succeeded and Task completed.
Authenticated active Dispatch reports worker_done outcome=failedSet Dispatch settled/failed and Task failed. Coordinator chooses recovery.
Stale or foreign Dispatch reportPersist as stale history; do not change current Task or Dispatch state.
Malformed lifecycle reportReject the transition and return the missing or invalid field.
worker-start returns failedSet Dispatch and Task failed with an explicit recovery reason.
Confirmed worker-stopSet Dispatch stopped and Task blocked; never claim task completion.
worker-stop accepted, termination unknown + Fence lifecycle authority, set Dispatch stop_unknown and Task + blocked, and retain the process/terminal in residualResources as + possibly live. A new start remains unsafe until inspection confirms termination + or the coordinator explicitly abandons with the concurrent-worker warning. +
Confirmed worker-abandon + Set Dispatch abandoned and Task blocked, fence its later reports, and retain + possibly-live resource IDs; never claim process termination. +
+
+ +

+ Inbox acknowledgment confirms recipient consumption only. Lifecycle reconciliation + happens once, atomically, when the Run home imports an authenticated message. Every + terminal Dispatch transition is a home-side transactional compare-and-set: the first + committed completion, stop fence, or abandon wins; later conflicting input is retained + only as stale history. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Question stateAsk/reply behavior
pending + ask from an active Dispatch defaults to its owning Run mailbox. The + first reply from the current authenticated Run consumer generation records the + answer at the Run home, relays it to the exact worker server, and wakes the + local asking call. +
answered + The pending ask or an explicit resume by the original Dispatch + returns the recorded answer. Repeating the same reply is harmless; a later + different answer conflicts. +
Wait timed out or client disconnected + Return the original question message ID and an exact + ask --resume msg_question_7 command without closing or duplicating + the question. Resume is allowed only for the original Dispatch. There is no + separate Question ID or durable expiry/deadline state machine. +
Acceptance response was lost + Retry the identical ask with its returned transport retry receipt + to recover the original message ID. A changed question conflicts; a blind new + ask is never the recovery path. +
Dispatch stopped or abandoned + The Run home atomically closes its pending questions, wakes local or resumed + waits with dispatch_inactive, and rejects later replies. +
+
+ + + +
+
# Worker: target defaults to the owning Run mailbox.
+orca orchestration ask --question "Should I preserve the legacy format?" --json
+# → question message msg_question_7
+# After a disconnect/timeout: orca orchestration ask --resume msg_question_7 --json
+
# Coordinator: reply to the returned message ID.
+orca orchestration reply --id msg_question_7 --body "Yes; preserve it." --json
+
+ +

+ Blocking is a coordinator decision. The runtime must make waiting race-free, but it + must not decide that the agent has exhausted all parallelizable work. +

+
+ +
+
+

Worker observation

+

Read a supervised worker by Dispatch, regardless of server.

+

+ worker-read --dispatch resolves the worker server and exact process from + the Dispatch receipt. Its default auto source returns the exact + hook-reported Codex, Claude, OpenClaude, or Grok transcript when Orca can prove that + association; + otherwise it returns bounded, explicitly labeled terminal output. Agents never + choose a server, provider session ID, or transcript path, and task authority never + comes from transcript prose. +

+
+ +
+
+

Default path

+

Automatic exact selection

+

+ --source auto uses a proven supported transcript and falls back to + the existing bounded terminal reader with a typed reason such as + session_not_reported or + remote_capability_unavailable. +

+
+
+

Explicit policy

+

Transcript or terminal

+

+ --source transcript requires exact structured output and returns a + typed error rather than falling back. --source terminal always uses + the retained terminal snapshot. +

+
+
+ +
orca orchestration worker-read --dispatch dispatch_7 --source auto --limit 100 --json
+
+# Exact transcript result for a worker on the saved environment named windows
+{
+  "dispatchId": "dispatch_7",
+  "source": "transcript",
+  "sourceIdentity": "opaque-source-fingerprint",
+  "provider": "codex",
+  "server": { "environmentId": "env_windows", "name": "windows" },
+  "remoteRuntimeEpoch": "runtime_epoch_2",
+  "transcript": {
+    "messages": [ ... ],
+    "nextCursor": "opaque-next-cursor",
+    "limited": false,
+    "returnedMessageCount": 12
+  },
+  "cursor": "opaque-next-cursor",
+  "status": { "worker": "ready", "terminal": "running" },
+  "fallbackReason": null,
+  "warnings": []
+}
+
+# Continue from the returned top-level opaque cursor.
+orca orchestration worker-read --dispatch dispatch_7 \
+  --cursor opaque-next-cursor --limit 100 --json
+ +
+
+

Source-pinned continuation

+

+ The returned cursor pins the Dispatch, process, source kind, and opaque source + identity. auto selects only on the first page. If the process or + provider session changes, Orca returns + worker_identity_changed or source_changed. +

+
+
+

Narrow provider readers

+

+ Structured reading reuses the existing bounded native transcript decoders only + for exact Codex, Claude, OpenClaude, and Grok associations. Other providers and + mixed-version peers retain terminal fallback; no resume, live-stream control, or + universal transcript framework is added. +

+
+
+ +

+ Every response labels source, sourceIdentity, + cursor, status, fallback reason, and bounded warnings. Terminal fallback + preserves the existing terminal fields and accepts legacy numeric cursors; new cursors + are opaque and never expose a transcript path. The worker-owning server performs the + read, and neither local nor federated selection may guess “latest session in this + directory.” +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Orca can sayOrca cannot infer
The active dispatch sent a completion report.The implementation is correct.
The managed process exited with a particular code.The reported tests actually passed unless Orca ran them itself.
A later exact-session adapter resolved these structured entries.The transcript describes the complete repository state.
The worker reported files, summary, and report path.Those files are exhaustive or the work has been integrated.
+
+
+ +
+
+

Implementation appendix · agents may skip this section

+

Hidden guarantees for one Run home and many worker servers.

+

+ A coordinator on one Orca server must be able to supervise workers on another—for + example, a Run on a Mac with Dispatches on both that Mac and a connected Windows + server. The minimum federation contract routes commands and durably relays messages; + it does not replicate the Run or add scheduling policy. +

+
+ +

+ Agent rule: choose a remote worker once with --on. After that, keep the + Dispatch ID and follow the returned inspection commands. Reuse + mutation.requestId only to recover the same request after a lost response. + Peer identity, sequencing, capabilities, and relay acknowledgments below are Orca + implementation details—not fields agents choose or copy. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Internal factV1 contractWhat agents see
Run home + The server where run-create executes owns the only authoritative + Run database: tasks, Dispatch state, inbox order, dedupe receipts, and consumer + generation. + The terminal binding routes ordinary commands home; no Run-home ID is typed.
Connected environment + The Run home stores its existing saved environment ID/name and authenticated + pairing for each worker server. Each Dispatch pins the authenticated peer + fingerprint captured at attachment, so re-pairing the saved environment to a + different server cannot retarget existing work. That relationship survives the + same remote server process restarting; the observed runtimeId + remains only an epoch. + --on windows when placement is explicit; receipts echo the name.
Remote Dispatch attachment + Before prompt injection, the worker server persists a verifier for the opaque + Dispatch capability, pinned Run-home peer identity, stable local pane and + process incarnation, effect receipts, and relay cursors. Credential material + is stored through current-user protected storage, not plaintext in a general + Run row. The worker server does not receive a copy of the Run DAG. + Nothing extra; the worker receives only Task and Dispatch IDs.
Caller pane + Each server's native, WSL, or SSH CLI bridge attaches the minted Dispatch + capability outside user parameters. The worker server verifies capability, + pane, and process incarnation; the Run home verifies the pinned authenticated + peer and Dispatch on relay import. + Orca authenticates lifecycle reports automatically.
+
+ +
+
+

Home-bound coordination

+

+ run-use/current, Task changes, check/ack, and replies go to the Run + home. Workers never choose or mutate the home, and there is no automatic home + failover. +

+
+
+

Server-owned resources

+

+ The selected worker server owns its worktree, terminal, process, and any nested + native/WSL/SSH/relay host. The Run home stores opaque receipts and routes show, + read, stop, and retry back to that owner. +

+
+
+

Durable relay, not replication

+

+ Worker-to-home lifecycle mail and home-to-worker replies remain queued at their + source until the destination imports and acknowledges them. Each item has a stable + ID, so reconnects are at-least-once on the wire and once in each inbox. +

+
+
+

Home-initiated connection

+

+ The Run home uses the same saved, authenticated environment connection already + used for remote RPC. It subscribes or pulls by cursor; the Windows server does not + need a separate pairing back to the Mac or a publicly reachable callback. +

+
+
+

Dispatch generations

+

+ Replacing a worker creates a new Dispatch ID; that ID is the generation. A report + from an older Dispatch or different pane remains history and cannot change current + Task state. There is no second agent-visible generation number. +

+
+
+

Idempotent control operations

+

+ Before effects, both home and worker server durably record authenticated peer, + request ID, canonical payload hash, operation state, and receipt. Identical + concurrent or later attempts join or return that record; a changed payload returns + request_mismatch. Receipts expose the opaque retry ID needed after an + unknown outcome. +

+
+
+

Typed unknown outcome

+

+ If a remote effect may have happened but Orca cannot prove it, return one + outcome_unknown shape with stage, mutation request ID, durable + effects/residuals, and exact inspection commands. +

+
+
+

Stop and replace

+

+ Stop first commits one home-side compare-and-set that fences new lifecycle changes, + blocks the Task, and closes pending questions, then best-effort stops only the + supervised agent process/terminal. If completion already won, stop returns + already_settled; if stop won, later completion is stale history. Orca + never deletes the worktree, setup output, or unrelated configured tabs. +

+
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Federated relay ruleExact contract
Worker send acceptance + Success means the worker server durably stored the authenticated message in + that Dispatch's outbound relay. The worker may finish even while the Run home + is offline. +
Home import and lifecycle + The Run home stores the message and applies any valid lifecycle transition in + one transaction before acknowledging it to the worker server. Until import, + the authoritative Task honestly remains dispatched. +
Replies and control mail + The Run home durably queues them for the exact remote Dispatch; the worker + server stores them before acknowledging the home and waking a local waiter. +
Ordering and duplicates + Each direction uses a scoped key of pinned peer, Dispatch ID, direction, and a + monotonic source sequence, plus a 128-bit-or-stronger message ID. A receiver + imports only the next contiguous sequence, buffers gaps, and acknowledges only + the highest contiguous commit. The home assigns normal inbox order at import, + without pretending simultaneous servers have a global clock. +
Bounded storage + Enforce per-message byte limits and per-Dispatch pending item/byte quotas, + coalesce heartbeats, and reserve space for one terminal lifecycle report. A + full relay returns relay_quota_exceeded; V1 adds no dead-letter or + retention workflow. +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Remote edgeV1 behavior
Worker server unavailable before send + Return remote_runtime_unavailable before creating a Dispatch or any + remote effect. No remote Dispatch attachment exists, so the caller may retry + normally after reconnecting. +
Connection lost after send + Return outcome_unknown, mutation.requestId, last durable + stage, and exact inspection commands. Repeating the request ID returns the + worker server's original receipt or accepts it once; it never duplicates an + effect. +
Run-home restart + Run state, dedupe receipts, and unacknowledged mail survive. The process runtime + ID is only an epoch. Reconnect resumes relay cursors for every active remote + Dispatch; if the coordinator pane cannot be safely reminted, + run-use explicitly rebinds it. +
Saved environment re-paired or removed + If its authenticated peer fingerprint differs from the Dispatch attachment, + return peer_changed with no effect; never adopt the replacement + server. Removing an environment with a nonterminal Dispatch retains a routing + tombstone for inspection and abandon rather than erasing ownership evidence. +
Servers disconnected after start + The worker may continue. Its server retains lifecycle mail and questions while + replies remain queued at the home. Silence is not failure, no worker is + automatically replaced, and other servers continue delivering normally. +
Worker-server restart + Remote Dispatch attachments, effect receipts, and unacknowledged relay items + survive. The process runtime ID may change; the Run home routes by its saved + environment relationship and pinned peer. worker-show reports + running only when the stable pane and process incarnation match; it never + adopts a same-looking pane or newly launched process. +
Mixed server versions + Before effects, both servers must advertise one aggregate + orchestrationFederationV1 contract. Missing support returns + capability_unsupported; + it never silently degrades to prompt injection, terminal scraping, or + consume-on-read mail. The worker-side mutation revalidates the pinned peer and + advertised protocol recorded for the operation, closing the probe-to-effect + race. +
Structured worker output + The worker-owning server reads an exact hook-reported Codex, Claude, + OpenClaude, or Grok transcript when supported. If the additive federated read + method is absent, auto returns bounded terminal output labeled with + remote_capability_unavailable; + transcript returns transcript_required. +
+
+ +

+ Capability checks cover only the new federation contracts, not every platform or host + feature. Existing worktree, setup, terminal, Git, WSL, SSH, and relay primitives keep + their proven compatibility behavior; optional, truthfully labeled observation may + degrade. +

+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
MechanismWhy it staysWhy it is not a scheduler
Server ownershipPrevents commands from acting on a same-looking resource on the wrong host.The agent chooses --on; the owner only determines routing.
Dispatch identityPrevents stale workers from overwriting current task state.It does not retry, replace, or start anything automatically.
Unknown outcomePrevents duplicate remote effects after a disconnect. + The receipt gives last durable stage and inspection command; the coordinator + chooses inspect, reconcile, retry, or abandon. +
+
+ +

+ Safety may reject stale, wrong-pane, or wrong-server control-plane mutations. It does + not police worker filesystem access, invent new work, choose a worker, or decide that + waiting is the coordinator's best next action. +

+ + +
+ +
+
+

Agent ergonomics

+

The skill should be a cookbook, not a second help page.

+

+ orca --help already owns syntax and exhaustive flags. The skill should + teach judgment: which topology to choose, how to preserve parallelism, what the + command returns, and what not to create afterward. +

+
+ +
+
+

Parallel fan-out

+

Start every independent task first. Only then call check --wait.

+
+
+

Shared-worktree review

+

Use the current worktree when sharing its exact state is useful.

+
+
+

Independent writers

+

Create separate worktrees when a concrete checkout conflict calls for isolation.

+
+
+

Ask and reply

+

Use a threaded question; continue other work while only that worker is blocked.

+
+
+

Read a full-screen agent

+

Use worker-read --dispatch; Orca routes to the owning server.

+
+
+

Replace safely

+

Inspect the stop result before deciding where to start a replacement.

+
+
+ +
+
+

Move out of the skill

+
    +
  • Complete command and flag catalogs
  • +
  • Internal database and delivery terminology
  • +
  • Provider-specific internals before the common path
  • +
  • Large decision trees for features Orca does not implement
  • +
+
+
+

Every recipe must say

+
    +
  • When the pattern is appropriate
  • +
  • What the command creates or reuses
  • +
  • Whether setup runs; if not, the concrete reason
  • +
  • How and when results return
  • +
  • The common misuse to avoid
  • +
+
+
+ +
+ Current Orca · parallel workers in the current worktree +
+

+ Creates two fresh agent terminals; setup does not run. Create both tasks and both + terminals before waiting for readiness, then dispatch both before blocking. The + coordinator keeps checking until both expected Dispatches settle; one batch is not + assumed to contain both completions. Misuse: waiting for worker A before starting B. +

+
orca orchestration task-create --spec "Audit message semantics" --json
+orca orchestration task-create --spec "Audit transcript adapters" --json
+orca terminal create --worktree active --title message-audit --command codex --json
+orca terminal create --worktree active --title transcript-audit --command codex --json
+orca terminal wait --terminal term_a --for tui-idle --timeout-ms 60000 --json
+orca terminal wait --terminal term_b --for tui-idle --timeout-ms 60000 --json
+orca orchestration dispatch --task task_a --to term_a --inject --json
+orca orchestration dispatch --task task_b --to term_b --inject --json
+
+# Repeat until task_a and task_b are both settled; process every returned message.
+orca orchestration check --wait --timeout-ms 60000 --json
+
+
+ +
+ Proposed Orca · same fan-out with composed worker start +
+

+ Creates one bound Run and two fresh terminals in the existing worktree. Setup is + not_applicable. Each start returns ready, failed, or + outcome_unknown; independent calls may be issued in parallel. Worker + results arrive through the inbox. Misuse: creating another terminal after start. +

+
orca orchestration run-create --objective "Improve orchestration ergonomics" --json
+orca orchestration task-create --spec "Audit message semantics" --json
+orca orchestration task-create --spec "Audit transcript adapters" --json
+ +
+
# Concurrent tool call A
+orca orchestration worker-start --task task_a --worktree current --agent codex --json
+
# Concurrent tool call B
+orca orchestration worker-start --task task_b --worktree current --agent codex --json
+
+ +
# After both start calls return, loop; do not assume two waits or one batch is enough.
+# Repeat process → ack → wait until both Dispatches are settled.
+orca orchestration check --wait --timeout-ms 60000 --json
+orca orchestration check --ack delivery_81 --wait --timeout-ms 60000 --json
+
+
+ +
+ Mac coordinator + Windows worker · one Run across connected servers +
+

+ The Run and inbox stay on the Mac. The first worker uses the Mac's current + worktree; the second creates a top-level worktree on the saved + windows Orca environment. Both report into the same inbox, including + after a temporary disconnect. Misuse: creating a second Run on Windows or asking + the worker to carry server/Run-home IDs. +

+
# On the Mac coordinator
+orca orchestration run-create --objective "Audit both platforms" --json
+orca orchestration task-create --spec "Audit macOS behavior" --json
+orca orchestration task-create --spec "Audit Windows behavior" --json
+
+# Read-only discovery happens against Windows; copy the opaque repo/worktree IDs returned.
+orca --environment windows worktree list --json
+
+# Issue these as independent concurrent tool calls, not as one sequential shell script.
+orca orchestration worker-start --task task_mac --worktree current --agent codex --json
+orca orchestration worker-start --task task_windows --on windows --worktree new-top-level --repo id:<windows-repo-id> --name windows-audit --agent codex --setup run --json
+
+# One home wait fans in local and Windows messages. Repeat until both Dispatches settle.
+orca orchestration check --wait --timeout-ms 60000 --json
+orca orchestration check --ack delivery_81 --wait --timeout-ms 60000 --json
+
+# Attempt-specific guidance uses the stable Dispatch, never the remote terminal handle.
+orca orchestration send --to dispatch:dispatch_windows --subject "Follow-up" \
+  --body "Run the additional Windows-only check." --json
+
+
+ +
+ Worker completion · success and failure are explicit +
+

+ The injected preamble supplies the only Task and Dispatch IDs a worker copies. + Orca supplies its Dispatch capability automatically; the worker never supplies Run, + runtime, host, terminal, capability, or attestation IDs. Misuse: reporting failure as a + successful completion or inventing IDs from terminal history. +

+
# Success
+orca orchestration send --type worker_done --subject "Review complete" \
+  --body "Audited the requested behavior. Found two issues and changed no files. Nothing remains." \
+  --task-id "<taskId from current preamble>" \
+  --dispatch-id "<dispatchId from current preamble>" \
+  --outcome succeeded --json
+
+# Failure
+orca orchestration send --type worker_done --subject "Review failed" \
+  --body "Could not read the required fixture. No findings are reliable. The fixture must be restored before retrying." \
+  --task-id "<taskId from current preamble>" \
+  --dispatch-id "<dispatchId from current preamble>" \
+  --outcome failed --json
+
+
+ +
+ Worker question · ask defaults to its Run +
+

+ An active worker asks its owning coordinator without carrying a Run ID. The + coordinator replies to the returned message ID while other work continues. Misuse: + creating a task gate or a second Question ID for a simple reply. +

+
# Worker
+orca orchestration ask --question "Should I preserve the legacy format?" --json
+
+# If acceptance may have happened but the response was lost, repeat the identical command with
+# the orchestrationRequestId reported by the CLI as --retry-request.
+orca orchestration ask --question "Should I preserve the legacy format?" --retry-request req_ask7 --json
+
+# If a server disconnect timed out the wait, resume with the returned message ID.
+orca orchestration ask --resume msg_question_7 --json
+
+# Coordinator, after receiving msg_question_7
+orca orchestration reply --id msg_question_7 --body "Yes; preserve it." --json
+
+
+ +
+ New child or top-level worktree · reuse the returned agent terminal +
+

+ New worktrees run configured setup by default. Agent-first creation returns the + only worker terminal; configured extra tabs remain intentional. Use + skip or inherit only for a concrete stated reason. Misuse: + adding a second agent terminal because the startup receipt was not inspected. +

+
orca orchestration worker-start --task task_a --worktree new-child --name message-audit --agent codex --setup run --json
+orca orchestration worker-show --dispatch dispatch_7 --json
+
+orca orchestration worker-start --task task_b --worktree new-top-level --name transcript-audit --agent claude --setup run --json
+
+# Escape hatch: this task audits the pristine fixture, and setup would mutate that fixture.
+orca orchestration worker-start --task task_fixture --worktree new-top-level --name fixture-only --agent codex --setup skip --json
+
+
+ +
+ Intentional existing-terminal reuse +
+

+ Reuses exactly the selected terminal and runs no setup. The terminal must belong to + the chosen worktree. Misuse: treating “an idle terminal somewhere” as equivalent. +

+
# Local existing worktree; copy the exact opaque IDs from worktree/terminal list.
+orca orchestration worker-start --task task_a --worktree 'id:<full-worktree-id>' --terminal term_12 --json
+
+# Remote existing worktree; --on is mandatory because V1 IDs are server-scoped.
+orca --environment windows worktree list --json
+orca orchestration worker-start --task task_b --on windows --worktree 'id:<full-windows-worktree-id>' --agent codex --json
+
+
+ +
+ Remote uncertainty · branch on what inspection proves +
+

+ An unknown start or stop is inspected by Dispatch ID; nothing is replayed merely + because a connection failed. Misuse: treating show → stop → abandon → retry as an + unconditional recovery sequence. +

+
# First recover the receipt for the exact request; this cannot create a second effect.
+orca orchestration worker-start --task task_a --worktree new-child --name message-audit --agent codex --setup run --retry-request req_7 --json
+
+# Then inspect the Dispatch if the outcome is still unknown.
+orca orchestration worker-show --dispatch dispatch_7 --json
+
+# If ready: keep the worker and wait for its result.
+# If failed or stopped: start an explicit replacement, repeating the intended placement.
+orca orchestration worker-start --task task_a --retry-of dispatch_7 --worktree current --agent codex --json
+
+# If still unknown: stop and inspect again, or explicitly accept the warning and abandon.
+orca orchestration worker-stop --dispatch dispatch_7 --json
+orca orchestration worker-show --dispatch dispatch_7 --json
+orca orchestration worker-abandon --dispatch dispatch_7 --json
+
+
+ +
+ Restart recovery · home and worker server are independent +
+

+ A Run-home restart preserves the Run, retry receipts, and the same unacknowledged + Delivery; run-use is needed only when Orca cannot safely remint the + previous coordinator pane. A worker-server restart preserves its Dispatch + attachment and relay queue but does not pretend the agent process survived. + Misuse: starting a replacement merely because a runtime ID changed. +

+
# Home restart: rebind only if run-current says this pane is unbound.
+orca orchestration run-current --json
+orca orchestration run-use --id run_123 --json
+orca orchestration check --wait --timeout-ms 60000 --json
+# → returns the same outstanding delivery_81 until it is acknowledged
+
+# Worker-server restart: inspect the persisted attachment and exact process incarnation.
+orca orchestration worker-show --dispatch dispatch_7 --json
+# running/ready → keep waiting; gone/failed → explicit retry-of; unknown → inspect or stop/abandon.
+
+
+ +

+ The skill must say that check --wait returns a batch. Process every message + before acknowledging it. +

+ +
+

Scenario tests matter more than keyword checks

+
+
    +
  • Reuse the terminal returned by worktree creation.
  • +
  • Start three independent workers before waiting.
  • +
  • Omit runId only when the coordinator terminal is explicitly bound.
  • +
  • Use current-worktree collaborators without unnecessary worktrees.
  • +
  • Fence an old consumer's acknowledgment after run-use rebinds.
  • +
  • Import duplicated and out-of-order relay frames only in contiguous order.
  • +
+
    +
  • Never treat a worker report as verified integration.
  • +
  • Never replay terminal input after unknown acceptance.
  • +
  • Page terminal output by Dispatch; use a session source only when exact.
  • +
  • Inspect an ambiguous remote stop before choosing the next action.
  • +
  • Let the first committed stop/completion transition win transactionally.
  • +
  • Reject a re-paired peer and never adopt a same-looking restarted process.
  • +
+
+
+
+ +
+
+

Complexity audit

+

Keep only the machinery required by Orca's concrete failure modes.

+

+ Every retained primitive below addresses a failure Orca can reproduce today. Broader + policy and product layers remain out of scope until a simpler primitive proves + insufficient in real use. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Observed Orca needKeepDeliberately defer
Lifecycle messages can be lost, injected into prompts, or consumed before a caller receives them.Durable typed mail, explicit acknowledgment, and race-free blocking waits.Dead-letter workflows, priority schedulers, watchdog policy, or a second queue product.
Terminal scrollback is not always the best available source, but session identity can be ambiguous.Terminal output as the baseline and an optional exact, source-pinned adapter.A universal session ontology, resume layer, or global provider exclusivity.
Remote acceptance, process exit, and worker claims provide different levels of evidence.Explicit lifecycle, fenced replacement, and typed unknown-outcome handling.Automatic retry, inferred success, rollback, or generalized recovery policy.
A Run home must coordinate workers across restarts and connected Orca servers.Stable Run, Task, and Dispatch identity with authenticated server relay.Role simulation, worker scoring, organization models, or integration queues.
+
+ +
+
+

Tests prove the contract

+

+ Scenario tests must demonstrate durable messages, authenticated relay, lifecycle + fencing, and truthful recovery. Design analogy is never a substitute for an + Orca-local executable contract. +

+
+
+

Complexity requires local evidence

+

+ A generalized scheduler, fairness policy, dead-letter workflow, or integration + system should be proposed only after Orca users demonstrate that the simpler + primitives cannot solve a recurring problem. +

+
+
+ + +
+ +
+
+

Implementation order

+

Each phase should remove one concrete source of agent confusion.

+

+ There is no UI phase. Each runtime change ships with a version-matched example and a + misuse test so the skill and behavior cannot drift apart. +

+
+ +
+
+
Phase 0
+
+

Rewrite the orchestration skill as recipes

+

+ Teach correct fan-out, current versus new worktree selection, startup terminal + reuse, --setup run for new worktrees unless the agent states a + concrete reason to skip or inherit, while preserving the existing + start-immediately default, current batch behavior, and blocking only + after useful parallel work is exhausted. Correct the one-message claim and + document today's delivery limits. + Rename or remove the existing scheduler-like orchestration run --spec + command before the lightweight run-* vocabulary can ship. +

+
+
+
+
Phase 1
+
+

Run mailbox, truthful completion, and crash-safe consumption

+

+ Add explicit Run-home binding, stable logical recipients, strict + inbox-only structured mail, succeeded/failed worker outcomes, one outstanding + FIFO batch, explicit acknowledgment, typed timeout results, ask/reply state, + a narrow runtime-minted Dispatch capability carried by the CLI bridge, and + resume-by-message-ID after a disconnected ask. Migrate existing global rows to + one unbound inspect-only legacy Run; do not infer bindings. Do not add separate + Question IDs or expiry policy. +

+
+
+
+
Phase 2
+
+

Local synchronous worker start and control

+

+ On the Run home, compose existing worktree, setup, terminal, and dispatch + primitives with setup-run as the new-worktree default, a durable request/stage + receipt, side-by-side setup/agent startup by default, startup-terminal reuse, + exact readiness, and Dispatch-routed + show/read/stop/abandon. Prove current, existing, + child, top-level, failure, restart, and unknown-outcome behavior before adding a + network boundary. Do not add a background provisioning executor. +

+
+
+
+
Phase 3
+
+

Connected-server Dispatch and relay

+

+ Extend the same primitives with saved-environment placement, pinned peer identity, + remote Dispatch attachments, paired-server calls, and bounded bidirectional relay + with contiguous cursors and idempotent acknowledgment. Validate Mac-home/Windows- + worker and Windows-home/Mac-worker completion, question/reply, read, stop, + either-side restart, re-pairing, disconnect, and mixed versions—without Run + replication, failover, or scheduling. +

+
+
+
+
Phase 4
+
+

Exact structured worker output

+

+ Reuse Orca's existing pane-scoped hook association and bounded + Codex/Claude/OpenClaude/Grok transcript decoders. Add + auto|transcript|terminal selection, + path-free source identity, opaque source-pinned paging, and labeled terminal + fallback without adding provider control or a universal transcript layer. +

+
+
+
+ +
+
+

Success looks like

+
    +
  • Agents start all independent work before waiting.
  • +
  • Lifecycle messages never flood editable coordinator input.
  • +
  • One Run can supervise Mac and Windows workers through one home inbox.
  • +
  • Remote completion and replies survive either server temporarily disconnecting.
  • +
  • No delivery is consumed before explicit acknowledgment.
  • +
  • Failed worker reports set failed—not completed—and remain worker assertions.
  • +
  • Worker start never reports a resource created before it exists.
  • +
  • Full-screen agent output remains readable when an adapter supports it.
  • +
  • Stale, wrong-pane, or wrong-server workers cannot mutate current task state.
  • +
+
+
+

Complexity budget

+
    +
  • No new concept without a common recipe that needs it.
  • +
  • No automatic action whose trigger an agent cannot explain.
  • +
  • No provider field that an adapter cannot actually observe.
  • +
  • No hidden default omitted from the operation receipt.
  • +
  • No control-plane fact derived from untrusted transcript prose.
  • +
  • No future feature included merely to keep the architecture open-ended.
  • +
+
+
+
+ +
+
+

Explicit boundaries

+

What this proposal intentionally does not build.

+

+ These are not hidden later phases. They require separate evidence and a separate + proposal if Orca eventually needs them. +

+
+ +
+
+

No product UI

+
    +
  • No dashboard or run/task view
  • +
  • No global inbox, badges, or queue screen
  • +
  • No coordinator chat surface
  • +
  • No task DAG visualization
  • +
  • No changes to existing Orca UI behavior
  • +
+
+
+

No scheduler

+
    +
  • No automatic task dispatch or placement
  • +
  • No capacity vectors or resource classes
  • +
  • No fairness, priority aging, or global queue
  • +
  • No pause, resume, or drain controls
  • +
  • No automatic retry based on silence
  • +
+
+
+

No integration subsystem

+
    +
  • No commit or branch tracking
  • +
  • No automatic merge or landing
  • +
  • No target-ref locking
  • +
  • No independent verification of worker claims
  • +
  • No cross-run work lineage model
  • +
+
+
+

No speculative framework

+
    +
  • No organization charts, roles, or worker profiles
  • +
  • No universal provider transcript schema
  • +
  • No dead-letter or poison-message workflow
  • +
  • No generalized continuation/checkpoint protocol
  • +
  • No project hierarchy above lightweight runs
  • +
  • No replicated Run database, leader election, or automatic home failover
  • +
+
+
+ +
+ Could Orca add these things later? +
+ Yes, but strong primitives do not need speculative abstractions for them today. A + future feature should compose the same start, message, wait, read, stop, identity, + and ownership contracts. It should justify its own concepts from observed Orca use. +
+
+ +
+ Does removing commit tracking make worker results less trustworthy? +
+ It makes the contract more honest. Today Orca verifies who is authorized to report a + result, not that every claim inside the result is true. A coordinator may explicitly + ask another worker to review or run validation. That is agent-directed orchestration, + not an implicit integration subsystem. +
+
+ +
+ What about connecting multiple Orca runtime servers? +
+ It is a core requirement. A Run stays authoritative on one home server while remote + Dispatches execute on saved connected environments such as a Windows machine. + Authenticated routing plus a small durable relay carries lifecycle mail and replies + across disconnects. This intentionally stops short of a global cluster: no Run + replication, leader election, automatic failover, distributed scheduler, or lease + manager is required. +
+
+ + +
+
+
+
+ + + + diff --git a/docs/reference/plans/2026-07-27-sta-2694-alt-screen-reveal-artifacts-handoff.md b/docs/reference/plans/2026-07-27-sta-2694-alt-screen-reveal-artifacts-handoff.md new file mode 100644 index 00000000000..5b68ea02916 --- /dev/null +++ b/docs/reference/plans/2026-07-27-sta-2694-alt-screen-reveal-artifacts-handoff.md @@ -0,0 +1,213 @@ +# STA-2694 — garbled terminal after switching away from an AI workspace + +Handoff snapshot: 2026-07-27, branch +`neil/sta-2694-fix-ui-rendering-artifacts-when-switching-away-from-ai`. + +## The report + +> When I use an AI tool—such as OpenCode—and switch away to the desktop or +> another task, the page appears garbled or distorted upon returning; I have to +> resize the window to restore the display. + +Reporter hints: related to terminal parking / workspace switching; also happens +with Claude Code and probably grok. + +## Status + +**One defect, root-caused and fixed, with a mechanism-level test that fails +without the fix.** The garble window is *unbounded*, not the ~1s I originally +claimed — see [the correction](#correction-the-window-is-unbounded). + +A second hypothesis was implemented, then **refuted by measurement and +reverted**. That refutation is the other half of the value here: it rules out a +whole class of "just force a fuller repaint" fixes, and it is pinned by a test so +it cannot be reintroduced. + +| | | +|---|---| +| Fix | `ed1eaf55f1` — release an abandoned synchronized-output frame on reveal | +| Refuted + reverted | `0f7ec4458d`, reverted in `8d5eacecb4` | +| Oracle + scope correction | `3ccffc17ec` | +| Unit tests | 8 passing across 2 files | +| e2e | 5 draw-command oracle + 10 headless convergence + 1 headful | +| Teeth-verified | Yes — removing the fix fails the stranded-latch test | + +## The defect + +Alt-screen agent TUIs (OpenCode/OpenTUI, Codex, grok) bracket every repaint in +DEC 2026 synchronized output — `\x1b[?2026h … \x1b[?2026l` — many times a second. +Hide a pane mid-bracket (a worktree switch, a cold park, or an OS-level occlusion +lands there routinely) and xterm keeps +`decPrivateModes.synchronizedOutput` latched `true`. + +`RenderService.refreshRows` checks that latch **before** rendering +(`RenderService.ts:162`), so while it holds, every repaint Orca owns is a no-op: +the forced render-pause repaint, the plain `refresh()` fallback, and the shared +glyph-atlas rebuild all render zero rows against a perfectly correct buffer. + +The fix (`terminal-synchronized-output-release.ts`) clears the latch and flushes +the handler's buffered row range at both reveal repaint entry points, before the +repaint. Both reveal paths reach it: + +- `schedulePaneRevealPresent` (plain refocus / desktop return) +- `resetWebglTextureAtlas` (worktree switch, cold park, tab reveal — also reached + from `resetAndRefreshAllTerminalWebglAtlases` via the light tab-resume path) + +### Correction: the window is unbounded + +`ed1eaf55f1`'s commit message says this "closes a bounded window rather than the +whole STA-2694 report", because xterm arms a 1s watchdog that clears the latch. +**That was wrong.** The watchdog is armed only inside +`SynchronizedOutputHandler.bufferRows`, and `refreshRows` returns at its +`_isPaused` check *first*: + +```ts +public refreshRows(start, end, sync = false, isRedrawOnly = false): void { + if (this._isPaused) { this._needsFullRefresh = true; return } // ← occluded pane stops here + if (this._coreService.decPrivateModes.synchronizedOutput) { + this._syncOutputHandler.bufferRows(start, end) // ← only place the watchdog arms + return + } + ... +``` + +While a pane is occluded its `IntersectionObserver` sets `_isPaused`, so nothing +ever reaches `bufferRows` and **no timer is ever pending**. A pane hidden +mid-frame holds the latch with no watchdog behind it, indefinitely — which is the +indefinite garble the report describes. `terminal-reveal-draw-command-probe.spec.ts` +asserts exactly this: latch set, `_timeout === undefined`, and a repaint drawing +0 instances. + +This also explains the specific workaround: resizing the window makes the TUI +repaint via SIGWINCH, and that repaint's closing `?2026l` clears the latch. + +## The refuted hypothesis (do not reimplement) + +The idea: xterm's renderers are diff-based, so a reveal `refresh()` skips cells +whose model entry still matches the buffer, leaving an occluded canvas showing +pre-hide pixels until a resize rebuilds the model. It is a *plausible* reading — +`WebglRenderer._updateModel` really does early-continue per unchanged cell. + +It is wrong, measured on a live pane: + +| Refresh | `updateCell` calls | instances drawn | +|---|---|---| +| diff-skipped (model matches buffer) | 0 | 562 | +| model cleared first | 561 | 562 | + +`GlyphRenderer.render` copies vertices for **every** row up to `lineLengths[y]` +into the active buffer and issues **one** full-viewport `drawElementsInstanced`. +The diff only decides how much *vertex data* is rewritten, never how much is +*drawn*. The DOM renderer likewise `replaceChildren()`s every row +unconditionally. So clearing the model cannot change what reaches the screen. + +Worse, it has a cost: `_clearModel(true)` zeroes every glyph vertex, and +`RenderService.clear()` fires no repaint of its own — so any paint landing between +the clear and the repopulating refresh draws an **empty** viewport (0 instances, +also asserted in the spec). On a reveal that refresh is debounced through a RAF +and can itself be swallowed, which would turn a merely-stale pane into a blank +one. Reverted in `8d5eacecb4`. + +The first test in the oracle spec pins the refutation so this cannot come back as +a "fix". + +## Why pixel oracles failed, and what replaced them + +The field defect is "the buffer is correct but the compositor shows pre-hide +pixels". Two pixel oracles were built and **both were proven blind** by injecting +that exact defect (freeze `RenderService.refreshRows`, then write new content): + +1. **Canvas-vs-buffer ink sampling.** `drawImage` on a + non-`preserveDrawingBuffer` WebGL canvas hands back a *re-rendered* copy — it + reported 0 missing cells against 5263 cells of text the canvas had never + drawn. +2. **Screenshot vs. a forced repaint.** Playwright's screenshot drives a fresh + compositor frame, which *heals* the stale paint before capture; and the + "repair" ran the same code the reveal already ran, so a shared defect cancels + out. + +An earlier resize-referenced oracle was also unsound: resizing shifts alt-screen +rows, so its 4.4% pixel diff measured legitimate reflow. + +**Pixels are the wrong layer** — anything that reads them can trigger the repaint +that hides the bug. `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` +instead wraps `GlyphRenderer.updateCell` and `gl.drawElementsInstanced` on the +live pane and counts draw commands. A draw command cannot be healed after the +fact, so "did the reveal repaint?" is directly observable — and falsifiable, +which is how the second hypothesis got refuted. + +Use that spec for **paint** questions; +`terminal-opencode-altscreen-reveal-artifacts.spec.ts` for **buffer, geometry and +PTY-size convergence** (worktree switch, cold park, idle agent, headful desktop +hide). The idle-agent case matters: every earlier test kept the TUI streaming +across the reveal, so live frames repainted whatever the reveal got wrong and the +defect healed itself before any assertion ran. + +## Leads closed by measurement + +Both were live suspects in the previous handoff; both are now ruled out. + +- **Dimension staleness.** Every dimension-rebuilding path in `WebglRenderer` is + behind an unchanged/zero-sized early exit that a hidden pane trips, and + `performSafeFit` early-returns at unchanged geometry — so nothing on the reveal + path re-runs `handleResize()`. Measured across a real `window.hide()` / + `show()`: canvas backing store `1272×1104` matched the renderer's computed + dimensions exactly, char metrics stayed `8.65×16`, `_isAttached` stayed true, + the screen element stayed connected. No staleness. +- **Lagging atlas page bindings.** Right after a reveal the bound texture version + *does* lag the atlas page (e.g. 1297 vs 1366) — but bindings are lazy and + `GlyphRenderer.render` rebinds any page whose version moved, so one repaint + brings them level (1366/1366). Normal, not a defect. Judge bindings only + *after* a draw. + +## Residual risk + +The oracle proves the repaint is *issued*; it does not photograph the user's +screen. If a report survives this fix, the remaining suspects are below the draw +call — GPU-process/compositor-level content loss — where the in-app sentinel on +real hardware is the tool: + +```js +localStorage.setItem('orca:render-desync-sentinel', '1') // then reload +``` + +Reproduce, then **⌘-click** (Ctrl-click off Mac) in the garbled pane. That starts +a 10s burst at 250ms intervals; a trip needs the same cells missing across 2 +consecutive samples, ≥200 text cells, ≥8% missing. It writes `corrupt.png` + +`corrupt.json` to `/terminal-render-desync-evidence//`, then +runs atlas recovery and captures the healed frame. Armed at import when the flag +is set (`terminal-freeze-breadcrumbs.ts:45`). + +## Verification commands + +```bash +npx vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/ +npx tsc --noEmit -p config/tsconfig.tc.web.json +npx playwright test tests/e2e/terminal-reveal-draw-command-probe.spec.ts \ + tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \ + tests/e2e/terminal-inline-tui-reveal-convergence.spec.ts \ + --config tests/playwright.config.ts --project=electron-headless --workers=1 +SKIP_BUILD=1 npx playwright test tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts \ + --config tests/playwright.config.ts --project=electron-headful --workers=1 +``` + +To confirm the fix still has teeth, delete the +`releaseAbandonedSynchronizedOutput(pane.terminal)` line from +`schedulePaneRevealPresent` and re-run the oracle: the stranded-latch test fails +with "the reveal repaint did not release the stranded latch, so the pane stays +frozen". + +## Files + +Production: + +- `src/renderer/src/lib/pane-manager/terminal-synchronized-output-release.ts` (the fix) +- `src/renderer/src/lib/pane-manager/pane-reveal-repaint.ts` (`schedulePaneRevealPresent`) +- `src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts` (`resetWebglTextureAtlas`) + +Tests: + +- `terminal-synchronized-output-release.test.ts`, `reveal-repaint-synchronized-output.test.ts` +- `tests/e2e/terminal-reveal-draw-command-probe.spec.ts` (the oracle) +- `tests/e2e/terminal-opencode-altscreen-reveal-artifacts.spec.ts`, + `tests/e2e/fixtures/opencode-altscreen-live-fixture.cjs` diff --git a/docs/reference/resource-manager-memory-metrics.md b/docs/reference/resource-manager-memory-metrics.md new file mode 100644 index 00000000000..c8c6627bebb --- /dev/null +++ b/docs/reference/resource-manager-memory-metrics.md @@ -0,0 +1,54 @@ +# Resource Manager memory metrics + +Resource Manager reports two different kinds of memory data. Process totals describe Orca and +locally managed terminal processes; host totals describe the machine running Orca. They are not +interchangeable. + +## Process memory + +Each snapshot declares one `processMemoryMetric`: + +| Platform | Metric | Source | +| ------------ | ------------- | ---------------------------------------------------------- | +| macOS, Linux | `rss` | `ps` resident set size | +| Windows | `working-set` | CIM `WorkingSetSize`, with a Typeperf working-set fallback | + +Orca walks each registered local PTY subtree and claims every PID at most once. App, session, +worktree, history, and snapshot memory values are sums of those per-process samples. + +RSS and working set are not unique physical-memory measurements. Shared pages can appear in more +than one process, and macOS can count the same resident page through multiple mappings in one +process. A sum can therefore exceed physical RAM. Product copy and diagnostics must identify the +metric as a sum and must not present it as a percentage of system RAM. + +Remote SSH and relay sessions do not receive invented local samples. They remain unavailable until +the executing host supplies resource data. Folder workspaces use the same registered-PTY +attribution as Git worktrees. + +## Host memory + +`HostMemory` keeps immediate free memory separate from memory available without material pressure: + +- `freeMemory` is Node's host free-memory value. +- `availableMemory` is the best bounded platform value. +- `usedMemory` is `totalMemory - availableMemory`. +- `memoryUsagePercent` is derived from `usedMemory`. +- `availableMemorySource` identifies how availability was measured. + +The sources are: + +| Platform | Preferred source | Fallback | +| -------- | ----------------------------------------- | ---------------- | +| macOS | `memory_pressure -Q` available percentage | Node free memory | +| Linux | `/proc/meminfo` `MemAvailable` | Node free memory | +| Windows | Node free memory | Node free memory | + +This percentage describes host availability. It is not derived from the summed process metric. + +## Bounded collection + +Resource Manager polls while its popover is open, so every snapshot must remain bounded under a +large process tree. macOS `footprint` provides a more physical process metric, but collecting it +across dozens of processes is too expensive for this path. A future physical-footprint backend +must use a bounded host API or helper and declare a distinct metric before product copy treats it +as physical memory. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 956349c70cb..d5fb6ebd1dc 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -1,8 +1,24 @@ +import { isBuiltin } from 'node:module' import { resolve } from 'node:path' -import { defineConfig } from 'electron-vite' +import { defineConfig, type UserConfig } from 'electron-vite' import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { createPlainNodeEntryGuardPlugin } from './build-plugins/plain-node-entry-guard' +import packageJson from './package.json' with { type: 'json' } + +const BUNDLED_MAIN_DEPENDENCIES = new Set(['@xterm/headless', '@xterm/addon-serialize']) +const EXTERNAL_MAIN_DEPENDENCIES = Object.keys(packageJson.dependencies).filter( + (dependency) => !BUNDLED_MAIN_DEPENDENCIES.has(dependency) +) + +function isExternalMainModule(source: string): boolean { + if (isBuiltin(source) || source === 'electron' || source.startsWith('electron/')) { + return true + } + return EXTERNAL_MAIN_DEPENDENCIES.some( + (dependency) => source === dependency || source.startsWith(`${dependency}/`) + ) +} // Why: the telemetry transport is gated by two compile-time constants that // only the official CI release workflow sets. Contributor / `pnpm dev` / @@ -165,7 +181,7 @@ function createStartupDiagnosticsBootstrapPlugin() { } } -export default defineConfig({ +export const electronViteConfig: UserConfig = { main: { build: { // Why: daemon-entry.js is asar-unpacked so child_process.fork() can @@ -176,9 +192,13 @@ export default defineConfig({ exclude: ['@xterm/headless', '@xterm/addon-serialize'] }, rollupOptions: { + // Why: native dependencies must resolve from packaged node_modules, + // while the unpacked daemon needs its pure-JS xterm graph bundled. + external: isExternalMainModule, input: { index: resolve('src/main/index.ts'), 'daemon-entry': resolve('src/main/daemon/daemon-entry.ts'), + 'plugin-host-entry': resolve('src/main/plugins/plugin-host-entry.ts'), 'computer-sidecar': resolve('src/main/computer/sidecar-entry.ts'), 'stt-worker': resolve('src/main/speech/stt-worker.ts'), 'warp-theme-parser-worker': resolve('src/main/warp-themes/warp-theme-parser-worker.ts'), @@ -200,6 +220,13 @@ export default defineConfig({ 'src/main/agent-hooks/managed-agent-hook-controls.ts' ) }, + // Why: Rolldown's SSR default is ESM, but Electron and sidecar launchers + // consume these stable CommonJS paths. + output: { + format: 'cjs', + entryFileNames: '[name].js', + chunkFileNames: 'chunks/[name]-[hash].js' + }, plugins: [createStartupDiagnosticsBootstrapPlugin(), createPlainNodeEntryGuardPlugin()] } }, @@ -241,17 +268,26 @@ export default defineConfig({ format: 'es' }, build: { + manifest: true, + modulePreload: { polyfill: true }, + target: 'es2020', // Why: the pop-out dashboard is a second top-level window with its own // React root. It gets its own HTML entry so it can boot independently of // the main window while reusing the same preload/window.api. `index` must // stay listed — overriding input otherwise drops electron-vite's default // renderer entry. rollupOptions: { + // Why: shared chunks must never import an HTML entry whose module mounts + // a different React root. + preserveEntrySignatures: 'strict', input: { index: resolve('src/renderer/index.html'), - popout: resolve('src/renderer/popout.html') + popout: resolve('src/renderer/popout.html'), + web: resolve('src/renderer/web-index.html') } } } } -}) +} + +export default defineConfig(electronViteConfig) diff --git a/examples/plugins/hello-orca/main.mjs b/examples/plugins/hello-orca/main.mjs new file mode 100644 index 00000000000..17dacd63722 --- /dev/null +++ b/examples/plugins/hello-orca/main.mjs @@ -0,0 +1,24 @@ +// Sample Orca plugin worker entry. Runs inside the out-of-process plugin +// worker (plain Node, no Electron), forked lazily on the first trigger. The +// default export receives the `orca` API: command registration, event +// handlers, and the capability-gated host API. +export default function activate(orca) { + orca.commands.register('hello-ping', async (args) => { + const stored = await orca.host.call('storage.get', { key: 'pings' }) + const count = (typeof stored?.value === 'number' ? stored.value : 0) + 1 + await orca.host.call('storage.set', { key: 'pings', value: count }) + return { pong: true, count, args: args ?? null } + }) + + orca.events.on('worktree.created', async (payload) => { + orca.log(`worktree created: ${payload.worktreeId} at ${payload.path}`) + await orca.host.call('notifications.show', { + title: 'Worktree created', + body: payload.path + }) + }) + + orca.events.on('agent.status.changed', (payload) => { + orca.log(`agent status: ${payload.state} in ${payload.worktreeId ?? 'unknown worktree'}`) + }) +} diff --git a/examples/plugins/hello-orca/orca-plugin.json b/examples/plugins/hello-orca/orca-plugin.json new file mode 100644 index 00000000000..da41d0f7e72 --- /dev/null +++ b/examples/plugins/hello-orca/orca-plugin.json @@ -0,0 +1,23 @@ +{ + "manifestVersion": 1, + "id": "hello-orca", + "publisher": "orca-samples", + "name": "Hello Orca", + "version": "1.0.0", + "description": "Sample plugin combining a sandboxed panel, a worker command, and event subscriptions.", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "main": "main.mjs", + "contributes": { + "panels": [{ "id": "hello", "title": "Hello Orca", "icon": "plug", "entry": "panel.html" }], + "commands": [{ "id": "hello-ping", "title": "Hello: Ping" }], + "events": [{ "on": "worktree.created" }, { "on": "agent.status.changed" }] + }, + "capabilities": [ + { "kind": "workspace:read" }, + { "kind": "terminal:send" }, + { "kind": "notifications:show" }, + { "kind": "storage" }, + { "kind": "events:subscribe" } + ] +} diff --git a/examples/plugins/hello-orca/panel.html b/examples/plugins/hello-orca/panel.html new file mode 100644 index 00000000000..6c7b5cf8181 --- /dev/null +++ b/examples/plugins/hello-orca/panel.html @@ -0,0 +1,125 @@ + + + + + + + +

Hello Orca 👋

+

Panel + worker command + events, gated by consent.

+ + + + +

+ + + diff --git a/examples/plugins/hostile-panel/orca-plugin.json b/examples/plugins/hostile-panel/orca-plugin.json new file mode 100644 index 00000000000..cf3a2f90a94 --- /dev/null +++ b/examples/plugins/hostile-panel/orca-plugin.json @@ -0,0 +1,16 @@ +{ + "manifestVersion": 1, + "id": "hostile-panel", + "publisher": "orca-samples", + "name": "Hostile Panel (security fixture)", + "version": "1.0.0", + "description": "Deliberately hostile panel used by the plugin containment tests: exfiltration, navigation, message floods, busy loops. Never grant it anything.", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "panels": [ + { "id": "hostile", "title": "Hostile Fixture", "icon": "bug", "entry": "panel.html" } + ] + }, + "capabilities": [] +} diff --git a/examples/plugins/hostile-panel/panel.html b/examples/plugins/hostile-panel/panel.html new file mode 100644 index 00000000000..d4f779333c6 --- /dev/null +++ b/examples/plugins/hostile-panel/panel.html @@ -0,0 +1,206 @@ + + + + + + + +

Hostile panel fixture

+
    + + + + + + + + + diff --git a/mobile/README.md b/mobile/README.md index 576e1efe9be..64f1081b73c 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -181,6 +181,19 @@ pnpm mock-server # starts mock WebSocket server on port 6768 Connect from the app using endpoint `ws://localhost:6768` and token `mock-device-token`. +### Environment variables + +- `MOCK_NATIVE_CHAT=1` — serve the native-chat scenario (one live agent tab, empty transcript, image upload) instead of the default terminal fixtures. +- `MOCK_SERVER_KEY_FILE` — persist the server keypair across restarts so a paired device keeps its public-key pin. A missing or invalid file is re-keyed with a warning, which forces a re-pair. + +### Scenario control files + +Read on every request, so behaviour can be flipped mid-session without a restart (a restart would re-key E2EE and force a re-pair). Write the mode into the file, or delete it for the default. + +- `MOCK_SEND_MODE_FILE` (default `orca-mock-send-mode` in the system temporary directory) — `accept` (default) accepts the send, `error` fails it with `mobile_input_floor_unavailable`, anything else reports the send as rejected. +- `MOCK_TERMINAL_LIST_MODE_FILE` (default `orca-mock-terminal-list-mode` in the system temporary directory) — `omit` returns an empty terminal list, `other` returns a list that omits the chat handle, anything else lists it. +- `MOCK_TERMINAL_STREAM_MODE_FILE` (default `orca-mock-terminal-stream-mode` in the system temporary directory) — `dead` answers a subscribe with `subscribed` then `end` (a gone PTY), which is what exercises the rearm bound and terminal prune; anything else streams normally. + ## Connecting to Real Orca 1. Start Orca desktop with WebSocket transport enabled diff --git a/mobile/app.json b/mobile/app.json index 6f55551e927..413cebc8d28 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Orca", "slug": "orca-mobile", - "version": "0.0.32", + "version": "0.0.36", "orientation": "default", "icon": "./assets/icon.png", "userInterfaceStyle": "automatic", @@ -75,7 +75,7 @@ "allowBackup": false, "permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"], "package": "com.stably.orca.mobile", - "versionCode": 8 + "versionCode": 9 }, "plugins": [ "expo-router", diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index 5eba95a619c..0b4b11b4449 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -211,9 +211,15 @@ import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrows import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller' import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability' import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease' +import { useMobileNativeChatSendError } from '../../../../src/session/use-mobile-native-chat-send-error' import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions' import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream' import { mobileNativeChatScopeKey } from '../../../../src/session/mobile-native-chat-scope-key' +import { + createTerminalPrunePredicate, + pruneTerminalKeyboardMetrics, + resolveRetainedTerminalHandles +} from '../../../../src/session/mobile-terminal-prune-decision' import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream' import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe' import { activateMobileSessionTab } from '../../../../src/session/mobile-session-tab-activation' @@ -223,6 +229,7 @@ import type { SessionTabsApplyOutcome, SessionTabsStreamSource } from '../../../../src/session/mobile-session-tabs-stream-health' +import { useMobileSessionTabsFetchReporting } from '../../../../src/session/use-mobile-session-tabs-fetch-reporting' import { useMobileSessionTabsReconciliation } from '../../../../src/session/use-mobile-session-tabs-reconciliation' import { getRepoIdFromMobileWorktreeId, @@ -898,6 +905,8 @@ export default function SessionScreen() { toggleTerminalLiveInput } = useTerminalLiveInputModePreference({ hostId, worktreeId }) const [activeHandle, setActiveHandle] = useState(null) + // Reactive teardown signal for the native-chat covered stream; see unsubscribeTerminal. + const [coveredStreamRevision, setCoveredStreamRevision] = useState(0) const [activeSessionTabId, setActiveSessionTabId] = useState(null) const activeSessionTabIdRef = useRef(null) // Auto-scroll the tab strip so the desktop-synced active tab is revealed without a manual scroll. @@ -1139,10 +1148,11 @@ export default function SessionScreen() { }, [clearToastHideTimer] ) - const showNativeChatSendError = useCallback( - (message: string) => showToast(message, 1600), - [showToast] - ) + const nativeChatScopeKey = mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId) + const nativeChatSendError = useMobileNativeChatSendError({ + scopeKey: nativeChatScopeKey, + showToast + }) const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId) const { ready: nativeChatInputLeaseReady, @@ -1164,9 +1174,12 @@ export default function SessionScreen() { deviceTokenRef, nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, - onSendError: showNativeChatSendError + connState, + onSendError: nativeChatSendError.show, + onSendResolved: nativeChatSendError.clear }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController + nativeChatSendError.bannerMountedRef.current = showNativeChat const dictation = useMobileDictation({ client, @@ -1303,9 +1316,25 @@ export default function SessionScreen() { subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1) // Why: reset the high-water mark so a fresh subscription's first scrollback isn't dropped as stale. layoutSeqRef.current.delete(handle) - clearNativeChatInputLease(handle) + // Why compare against the RENDERED lease: `clear` reports the drop from its + // synchronous mirror, so a `subscribed`+`end` pair applied in one render batch + // reports "dropped" while React only ever sees false → the effect never re-runs + // and the composer stays locked (#10681). A dead PTY can also emit `end` with no + // preceding `subscribed`, where the clear is a no-op for the same reason. Either + // way the flip carries no signal, so bump. When the lease really was up on + // screen, `leaseReady` already re-runs the effect and bumping too would + // double-render this whole route on every chat open. + const leaseWasOnScreen = nativeChatInputLeaseReadyRef.current + const leaseDropped = clearNativeChatInputLease(handle) + if ( + (!leaseDropped || !leaseWasOnScreen) && + showNativeChatRef.current && + handle === activeHandleRef.current + ) { + setCoveredStreamRevision((revision) => revision + 1) + } }, - [clearNativeChatInputLease] + [clearNativeChatInputLease, nativeChatInputLeaseReadyRef, showNativeChatRef] ) const unsubscribeTerminalRef = useRef(unsubscribeTerminal) unsubscribeTerminalRef.current = unsubscribeTerminal @@ -1563,10 +1592,12 @@ export default function SessionScreen() { [client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction] ) - const notifyTerminalWebReady = useMobileNativeChatTerminalStream({ + const nativeChatStream = useMobileNativeChatTerminalStream({ showNativeChat, activeHandle, activeTabType: activeSessionTab?.type ?? null, + leaseReady: nativeChatInputLeaseReady, + streamRevision: coveredStreamRevision, subscriptionsRef: terminalUnsubsRef, subscribingRef: subscribingHandlesRef, webReadyRef: webReadyHandlesRef, @@ -1630,7 +1661,6 @@ export default function SessionScreen() { }) if (response.ok) { const result = (response as RpcSuccess).result as { terminals: Terminal[] } - if (result.terminals.length === 0 && !allowEmptyLoaded) { return } @@ -1641,25 +1671,31 @@ export default function SessionScreen() { } const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle)) - // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. - pruneTerminalHandlesFromLiveInput(liveHandles) - defaultTerminalHandlesToLiveInput([...liveHandles]) - for (const handle of Array.from(terminalUnsubsRef.current.keys())) { - if (!liveHandles.has(handle)) { - unsubscribeTerminal(handle) - terminalRefs.current.delete(handle) - initializedHandlesRef.current.delete(handle) - clearTerminalLiveInputDefault(handle) - setTerminalKeyboardMetrics((prev) => { - if (!prev.has(handle)) { - return prev - } - const next = new Map(prev) - next.delete(handle) - return next - }) - } + const pruneContext = { + liveHandles, + showNativeChat: showNativeChatRef.current, + activeHandle: activeHandleRef.current } + // Why: terminal.list is the lifetime signal; lagging tab snapshots must not erase a user's buffered-mode opt-out. + // Sweep against the retained set, not the raw list: a chat-covered handle + // keeps its subscription across a graph reload, so erasing its live-input + // preference on the same refresh is the erasure this guard exists to stop. + pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext)) + defaultTerminalHandlesToLiveInput([...liveHandles]) + const shouldPrune = createTerminalPrunePredicate(pruneContext) + for (const handle of Array.from(terminalUnsubsRef.current.keys())) { + if (!shouldPrune(handle)) { + continue + } + unsubscribeTerminal(handle) + terminalRefs.current.delete(handle) + initializedHandlesRef.current.delete(handle) + clearTerminalLiveInputDefault(handle) + } + setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune)) + // Why: a chat-covered handle the host reports again refills its rearm budget, + // so an exhausted rearm can't lock the composer until leave-chat. + nativeChatStream.notifyListedHandles(liveHandles) lastKnownTerminalCountRef.current = result.terminals.length // Why: dedupe duplicate handles (rename/split race) to avoid a React duplicate-key throw; keep first for tab-strip order. const seen = new Set() @@ -1694,6 +1730,7 @@ export default function SessionScreen() { worktreeId, clearTerminalLiveInputDefault, defaultTerminalHandlesToLiveInput, + nativeChatStream, pruneTerminalHandlesFromLiveInput, subscribeToTerminal, unsubscribeTerminal @@ -1807,6 +1844,10 @@ export default function SessionScreen() { activeSessionTabIdRef.current = nextActiveTabId setActiveSessionTabId(nextActiveTabId) activeSessionTabTypeRef.current = 'terminal' + // Why: every other active-handle branch assigns the ref alongside the + // state. Leaving it stale here makes `covered` resolve against the wrong + // handle, so a native-chat rearm silently no-ops on the webview gates. + activeHandleRef.current = pendingActiveTerminalHandle setActiveHandle(pendingActiveTerminalHandle) subscribeToTerminal(pendingActiveTerminalHandle) return outcome @@ -2301,25 +2342,23 @@ export default function SessionScreen() { [] ) const hasSessionTabsRecoveryNeed = useCallback( - () => closedTabTombstonesRef.current.size > 0 || pendingBrowserFocusPageIdRef.current !== null, - [] + () => + closedTabTombstonesRef.current.size > 0 || + pendingBrowserFocusPageIdRef.current !== null || + // Why: a chat-covered handle that ran out of rearms and left `terminal.list` + // was reminted by a desktop graph reload. Only a fresh tab snapshot carries + // the replacement handle, so force one instead of holding the composer locked. + nativeChatStream.hasTabsRecoveryNeed(), + [nativeChatStream] ) const getSessionTabsApplicationRevision = useCallback( () => appliedSessionTabsRevisionRef.current, [] ) - const reportSessionTabsFetchStarted = useCallback(() => { - terminalDiagnosticsRef.current.tabsFetchStarted(worktreeId) - }, [worktreeId]) - const reportSessionTabsFetchSucceeded = useCallback((result: SessionTabsResult) => { - terminalDiagnosticsRef.current.tabsFetchSucceeded(result) - }, []) - const reportSessionTabsFetchFailed = useCallback((code: string) => { - terminalDiagnosticsRef.current.tabsFetchFailed(code) - }, []) - const reportSessionTabsFetchErrored = useCallback((error: unknown) => { - terminalDiagnosticsRef.current.tabsFetchErrored(error) - }, []) + const sessionTabsFetchReporting = useMobileSessionTabsFetchReporting({ + worktreeId, + diagnosticsRef: terminalDiagnosticsRef + }) const { fetchSessionTabs, ensureSessionTabs, fetchPendingBrowserSessionTabs } = useMobileSessionTabsReconciliation({ client, @@ -2330,10 +2369,7 @@ export default function SessionScreen() { fetchTerminals, hasRecoveryNeed: hasSessionTabsRecoveryNeed, getApplicationRevision: getSessionTabsApplicationRevision, - onFetchStarted: reportSessionTabsFetchStarted, - onFetchSucceeded: reportSessionTabsFetchSucceeded, - onFetchFailed: reportSessionTabsFetchFailed, - onFetchErrored: reportSessionTabsFetchErrored + ...sessionTabsFetchReporting }) useEffect(() => { @@ -2900,7 +2936,7 @@ export default function SessionScreen() { (handle: string) => { const wasAlreadyReady = webReadyHandlesRef.current.has(handle) webReadyHandlesRef.current.add(handle) - notifyTerminalWebReady(handle, wasAlreadyReady) + nativeChatStream.notifyWebReady(handle, wasAlreadyReady) terminalDiagnosticsRef.current.webViewReady( handle, wasAlreadyReady, @@ -2928,7 +2964,7 @@ export default function SessionScreen() { })() } }, - [measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal] + [measureViewportOnce, nativeChatStream, subscribeToTerminal, unsubscribeTerminal] ) useEffect(() => { @@ -3631,12 +3667,13 @@ export default function SessionScreen() { canSend, connState, deviceTokenRef, - nativeChatScopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId), + nativeChatScopeKey, nativeChatInputLeaseReady, getActiveWorktreeConnectionId, beforeTerminalSend: flushPendingLiveInputBeforeAttachmentSend, nativeChatBaseSend: nativeChatController.handleNativeChatSendWithOutcome, showToast, + onNativeChatSendError: nativeChatSendError.show, onSuccess: triggerSelection, onError: triggerError }) @@ -4720,6 +4757,8 @@ export default function SessionScreen() { onMicPressIn={handleDictationPressIn} onMicPressOut={handleDictationPressOut} inputLockReason={nativeChatInputLockReason} + sendErrorMessage={nativeChatSendError.message} + onClearSendError={nativeChatSendError.clear} keyboardInset={keyboardLift} /> {toastMessage && ( diff --git a/mobile/fastlane/Fastfile b/mobile/fastlane/Fastfile index 3ceb91c0d97..d08ce9462f1 100644 --- a/mobile/fastlane/Fastfile +++ b/mobile/fastlane/Fastfile @@ -36,8 +36,15 @@ DEFAULT_TESTFLIGHT_CHANGELOG = "Latest Orca Mobile updates and fixes.".freeze # closed"). Only approved/released/removed states qualify: a version that is # merely IN_REVIEW / WAITING_FOR_REVIEW / PROCESSING_FOR_APP_STORE still accepts # TestFlight builds, so bumping on those would break normal beta iteration. +# +# Covers both vocabularies: `appStoreState` is deprecated as of App Store +# Connect API 3.3 in favor of `appVersionState`, which renames the shipped state +# (READY_FOR_SALE -> READY_FOR_DISTRIBUTION) and drops the removed-from-sale +# ones. Reading whichever field Apple populates keeps the guard working through +# the transition instead of silently finding zero closed versions. CLOSED_APP_STORE_STATES = %w[ READY_FOR_SALE + READY_FOR_DISTRIBUTION PENDING_DEVELOPER_RELEASE PENDING_APPLE_RELEASE REPLACED_WITH_NEW_VERSION @@ -67,22 +74,42 @@ def current_mobile_version(config) config.fetch("expo").fetch("version") end -# Returns true when `version`'s App Store train is closed to new build uploads. -# `app` is a Spaceship::ConnectAPI::App the caller looks up (nil if lookup -# failed). On a nil app or any API error, degrade to "open": we then proceed as -# before this check existed — the upload either succeeds or fails with the same -# 90186 we have always seen, never worse than today's behavior. -def version_train_closed?(app, version) - return false unless app +# True when either state field reports a terminally closed train. `respond_to?` +# guards the newer field, which older spaceship versions do not define. +def closed_state?(app_store_version) + states = [app_store_version.app_store_state] + states << app_store_version.app_version_state if app_store_version.respond_to?(:app_version_state) - app - .get_app_store_versions(filter: { versionString: version }) - .any? { |app_store_version| CLOSED_APP_STORE_STATES.include?(app_store_version.app_store_state) } + states.compact.any? { |state| CLOSED_APP_STORE_STATES.include?(state) } +end + +# Highest version whose App Store record is in a terminally closed state, or nil +# when none is (or the lookup fails). Fetched once because the answer is a +# property of the app, not of any single candidate version. +# +# Why the whole list rather than a per-version lookup: a version only gets an +# App Store record once someone submits it. 0.0.34 was uploaded to TestFlight +# but never submitted, so it had no record and a filtered lookup found nothing +# closed — while 0.0.35 shipped, which closes 0.0.34 too (Apple: 90062 requires +# a *higher* version than the last approved one). +# +# On a nil app or any API error, degrade to "open": we then proceed as before +# this check existed — the upload either succeeds or fails with the same 90186 +# we have always seen, never worse than today's behavior. +def highest_closed_app_store_version(app) + return nil unless app + + closed = app + .get_app_store_versions + .select { |app_store_version| closed_state?(app_store_version) } + .map(&:version_string) + + IosReleaseVersion.max_version(closed) rescue StandardError => error # Loud, not silent: a swallowed error here un-fixes the 90186 guard, so the # degraded run must be visible rather than buried. - UI.error("Could not determine App Store state for #{version} (#{error.message}); assuming open and proceeding.") - false + UI.error("Could not determine closed App Store versions (#{error.message}); assuming open and proceeding.") + nil end def testflight_changelog @@ -103,13 +130,16 @@ platform :ios do UI.error("Could not look up App Store app #{BUNDLE_ID} (#{error.message}); skipping closed-train check.") nil end + highest_closed = highest_closed_app_store_version(app) + UI.message("Highest closed App Store version: #{highest_closed || 'none'}") + version = begin IosReleaseVersion.resolve( requested: options[:version], bump_patch: options[:bump_patch], current_version: current_mobile_version(config), - train_closed: ->(candidate) { version_train_closed?(app, candidate) }, + train_closed: ->(candidate) { IosReleaseVersion.closed_train?(candidate, highest_closed) }, ) rescue ArgumentError => error UI.user_error!(error.message) @@ -117,7 +147,7 @@ platform :ios do # Explicit and checked-in versions still fail fast when closed. Patch bumps # skip closed trains above because workflow-only releases can outpace Git. - if version_train_closed?(app, version) + if IosReleaseVersion.closed_train?(version, highest_closed) retry_guidance = if IosReleaseVersion.truthy?(options[:bump_patch]) "Use a higher release_version or land a \"Prepare mobile \" commit." @@ -126,8 +156,8 @@ platform :ios do "or land a \"Prepare mobile \" commit." end UI.user_error!( - "iOS version #{version} is already submitted/released on the App Store and cannot accept " \ - "new builds. #{retry_guidance}", + "iOS version #{version} is not higher than #{highest_closed}, which is already " \ + "submitted/released on the App Store, so Apple will reject the upload. #{retry_guidance}", ) end diff --git a/mobile/fastlane/ios_release_version.rb b/mobile/fastlane/ios_release_version.rb index 2b4fda40e06..ae3cdd6e050 100644 --- a/mobile/fastlane/ios_release_version.rb +++ b/mobile/fastlane/ios_release_version.rb @@ -1,15 +1,47 @@ module IosReleaseVersion module_function + SEMVER_PATTERN = /\A(\d+)\.(\d+)\.(\d+)\z/.freeze + def truthy?(value) %w[1 true yes on].include?(value.to_s.strip.downcase) end - def bump_patch(version) - match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/) - raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless match + # [major, minor, patch] for semver comparison, or nil for anything else. + def parse(version) + match = version.to_s.strip.match(SEMVER_PATTERN) + return nil unless match - "#{match[1]}.#{match[2]}.#{match[3].to_i + 1}" + [match[1].to_i, match[2].to_i, match[3].to_i] + end + + def bump_patch(version) + parts = parse(version) + raise ArgumentError, "Cannot bump non-semver mobile version '#{version}'" unless parts + + "#{parts[0]}.#{parts[1]}.#{parts[2] + 1}" + end + + # Highest semver in `versions`, skipping entries App Store Connect reports in + # a non-semver shape. Compares numerically: "0.0.10" beats "0.0.9", which a + # string sort gets backwards. + def max_version(versions) + Array(versions) + .map { |version| version.to_s.strip } + .select { |version| parse(version) } + .max_by { |version| parse(version) } + end + + # Apple rejects an upload whose CFBundleShortVersionString is not higher than + # the last approved version (90062) and reports that version's train as closed + # (90186). So every version at or below `highest_closed` is unusable, not just + # the ones whose own App Store record sits in a closed state. + def closed_train?(version, highest_closed) + candidate = parse(version) + ceiling = parse(highest_closed) + return false unless candidate && ceiling + + (candidate <=> ceiling) <= 0 end def resolve(requested:, bump_patch:, current_version:, train_closed:) diff --git a/mobile/fastlane/ios_release_version_test.rb b/mobile/fastlane/ios_release_version_test.rb index 795e6748275..c14c19aeda4 100644 --- a/mobile/fastlane/ios_release_version_test.rb +++ b/mobile/fastlane/ios_release_version_test.rb @@ -45,4 +45,51 @@ class IosReleaseVersionTest < Minitest::Test assert_equal("Cannot bump non-semver mobile version '0.0'", error.message) end + + # The 0.0.34 regression: 0.0.35 shipped, so every version at or below it is + # closed even though 0.0.34 itself never got an App Store record. + def test_versions_at_or_below_the_highest_closed_version_are_closed + assert(IosReleaseVersion.closed_train?("0.0.34", "0.0.35")) + assert(IosReleaseVersion.closed_train?("0.0.35", "0.0.35")) + refute(IosReleaseVersion.closed_train?("0.0.36", "0.0.35")) + end + + def test_nothing_is_closed_without_a_known_closed_version + refute(IosReleaseVersion.closed_train?("0.0.1", nil)) + refute(IosReleaseVersion.closed_train?("0.0.1", "")) + end + + def test_non_semver_candidates_are_treated_as_open + refute(IosReleaseVersion.closed_train?("0.0", "0.0.35")) + end + + def test_resolve_skips_past_the_highest_closed_version + version = IosReleaseVersion.resolve( + requested: "", + bump_patch: true, + current_version: "0.0.32", + train_closed: ->(candidate) { IosReleaseVersion.closed_train?(candidate, "0.0.35") }, + ) + + assert_equal("0.0.36", version) + end + + def test_max_version_compares_numerically_not_lexically + assert_equal("0.0.10", IosReleaseVersion.max_version(%w[0.0.9 0.0.10 0.0.2])) + assert_equal("0.2.0", IosReleaseVersion.max_version(%w[0.1.99 0.2.0])) + assert_equal("1.0.0", IosReleaseVersion.max_version(%w[0.9.9 1.0.0])) + end + + def test_max_version_ignores_non_semver_entries + assert_equal("0.0.35", IosReleaseVersion.max_version(["0.0.35", "1.0", "", nil])) + assert_nil(IosReleaseVersion.max_version([])) + assert_nil(IosReleaseVersion.max_version(nil)) + end + + # Minor/major releases must close stale patch trains beneath them. + def test_closed_train_compares_across_minor_and_major + assert(IosReleaseVersion.closed_train?("0.0.99", "0.1.0")) + assert(IosReleaseVersion.closed_train?("0.9.9", "1.0.0")) + refute(IosReleaseVersion.closed_train?("1.0.1", "1.0.0")) + end end diff --git a/mobile/scripts/mock-server-key-pair.ts b/mobile/scripts/mock-server-key-pair.ts new file mode 100644 index 00000000000..915ba1eaf75 --- /dev/null +++ b/mobile/scripts/mock-server-key-pair.ts @@ -0,0 +1,167 @@ +import { randomUUID } from 'node:crypto' +import { + chmodSync, + closeSync, + openSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync +} from 'node:fs' +import nacl from 'tweetnacl' + +const LOCK_ATTEMPTS = 50 +const LOCK_WAIT_MS = 10 +const RENAME_ATTEMPTS = 5 +const RENAME_WAIT_MS = 25 +const lockWaitSignal = new Int32Array(new SharedArrayBuffer(4)) + +type KeyReadResult = + | { keyPair: nacl.BoxKeyPair; reason?: never } + | { keyPair?: never; reason: string } + +type KeyLockResult = + | { fd: number; lockFile: string; keyPair?: never } + | { fd?: never; lockFile?: never; keyPair: nacl.BoxKeyPair } + +type KeyWarningLogger = Pick + +function errnoCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | null)?.code +} + +function readKeyPair(keyFile: string): KeyReadResult { + try { + const encoded = readFileSync(keyFile, 'utf-8').trim() + if (!encoded) { + return { reason: 'empty' } + } + const decoded = Buffer.from(encoded, 'base64') + if (decoded.toString('base64') !== encoded) { + return { reason: 'invalid base64' } + } + if (decoded.length !== nacl.box.secretKeyLength) { + return { reason: `wrong length (${decoded.length} bytes)` } + } + return { keyPair: nacl.box.keyPair.fromSecretKey(Uint8Array.from(decoded)) } + } catch (error) { + return { reason: errnoCode(error) === 'ENOENT' ? 'missing' : 'unreadable' } + } +} + +function acquireKeyLock(keyFile: string): KeyLockResult { + const lockFile = `${keyFile}.lock` + for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) { + try { + return { fd: openSync(lockFile, 'wx', 0o600), lockFile } + } catch (error) { + if (errnoCode(error) !== 'EEXIST') { + throw error + } + const concurrent = readKeyPair(keyFile) + if (concurrent.keyPair) { + return { keyPair: concurrent.keyPair } + } + if (attempt < LOCK_ATTEMPTS - 1) { + Atomics.wait(lockWaitSignal, 0, 0, LOCK_WAIT_MS) + } + } + } + const winner = readKeyPair(keyFile) + if (winner.keyPair) { + return { keyPair: winner.keyPair } + } + try { + return { fd: openSync(lockFile, 'wx', 0o600), lockFile } + } catch (error) { + if (errnoCode(error) !== 'EEXIST') { + throw error + } + const lateWinner = readKeyPair(keyFile) + if (lateWinner.keyPair) { + return { keyPair: lateWinner.keyPair } + } + } + throw new Error( + `[mock] Key file lock ${lockFile} remained busy; remove it if no mock server is running` + ) +} + +function renameKeyFile(temporaryFile: string, keyFile: string): void { + for (let attempt = 0; attempt < RENAME_ATTEMPTS; attempt += 1) { + try { + renameSync(temporaryFile, keyFile) + return + } catch (error) { + if ( + !['EACCES', 'EBUSY', 'EPERM'].includes(errnoCode(error) ?? '') || + attempt === RENAME_ATTEMPTS - 1 + ) { + throw error + } + Atomics.wait(lockWaitSignal, 0, 0, RENAME_WAIT_MS) + } + } +} + +function persistKeyPair(keyFile: string, keyPair: nacl.BoxKeyPair): void { + const temporaryFile = `${keyFile}.${process.pid}.${randomUUID()}.tmp` + try { + writeFileSync(temporaryFile, Buffer.from(keyPair.secretKey).toString('base64'), { + flag: 'wx', + mode: 0o600 + }) + if (process.platform !== 'win32') { + chmodSync(temporaryFile, 0o600) + } + renameKeyFile(temporaryFile, keyFile) + } finally { + try { + rmSync(temporaryFile, { force: true }) + } catch {} + } +} + +function releaseKeyLock(lock: { fd: number; lockFile: string }): void { + try { + closeSync(lock.fd) + } catch {} + try { + unlinkSync(lock.lockFile) + } catch {} +} + +export function loadOrCreateMockServerKeyPair( + keyFile: string | undefined, + logger: KeyWarningLogger = console +): nacl.BoxKeyPair { + if (!keyFile) { + return nacl.box.keyPair() + } + const existing = readKeyPair(keyFile) + if (existing.keyPair) { + return existing.keyPair + } + + const lock = acquireKeyLock(keyFile) + if (lock.keyPair) { + return lock.keyPair + } + let selected: nacl.BoxKeyPair + try { + const current = readKeyPair(keyFile) + if (current.keyPair) { + selected = current.keyPair + } else { + logger.warn( + `[mock] Key file ${keyFile} is ${current.reason} — minting a fresh key; paired devices must re-pair` + ) + selected = nacl.box.keyPair() + persistKeyPair(keyFile, selected) + } + } finally { + releaseKeyLock(lock) + } + return selected +} diff --git a/mobile/scripts/mock-server-native-chat-scenario.ts b/mobile/scripts/mock-server-native-chat-scenario.ts new file mode 100644 index 00000000000..d2f65e0b635 --- /dev/null +++ b/mobile/scripts/mock-server-native-chat-scenario.ts @@ -0,0 +1,275 @@ +import { readFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { WebSocket } from 'ws' +import type { AgentStatusEntry } from '../../src/shared/agent-status-types' +import type { + RuntimeMobileSessionTabsResult, + RuntimeMobileSessionTerminalClientTab +} from '../../src/shared/runtime-types' +import type { RpcRequest, RpcResponse } from './mock-server-rpc-handlers' + +// Why: native chat needs a live agent tab, an empty-but-subscribed transcript, +// and a terminal send path whose acceptance can be flipped mid-session. +// Restarting the server re-keys E2EE (forcing a re-pair), so send behaviour is +// read from a control file on every request instead of an env var. +const SEND_MODE_FILE = process.env.MOCK_SEND_MODE_FILE ?? join(tmpdir(), 'orca-mock-send-mode') +const TERMINAL_LIST_MODE_FILE = + process.env.MOCK_TERMINAL_LIST_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-list-mode') +// Write `dead` here to reproduce a gone PTY: the host answers with `subscribed` +// then `end`, which is the shape the rearm bound and terminal prune react to. +const TERMINAL_STREAM_MODE_FILE = + process.env.MOCK_TERMINAL_STREAM_MODE_FILE ?? join(tmpdir(), 'orca-mock-terminal-stream-mode') +const TERMINAL_HANDLE = 'chat-term-1' +const TAB_ID = 'chat-tab-1' +const SESSION_ID = 'mock-chat-session' +const TRANSCRIPT_PATH = join(tmpdir(), 'mock-transcript.jsonl') +const MOCK_IMAGE_PATH = join(tmpdir(), 'mock-image.png') + +function readControl(file: string): string { + try { + return readFileSync(file, 'utf-8').trim() + } catch { + // Default to the happy path when the control file is absent. + return '' + } +} + +const tabsSnapshots = new Map() +const agentStatus: AgentStatusEntry = { + state: 'done', + prompt: '', + updatedAt: Date.now(), + stateStartedAt: Date.now(), + agentType: 'claude', + paneKey: `${TAB_ID}:leaf-1`, + terminalHandle: TERMINAL_HANDLE, + stateHistory: [], + providerSession: { + key: 'session_id', + id: SESSION_ID, + transcriptPath: TRANSCRIPT_PATH + } +} + +function buildTab(): RuntimeMobileSessionTerminalClientTab { + return { + type: 'terminal', + id: TAB_ID, + title: 'Claude Code', + parentTabId: TAB_ID, + leafId: 'leaf-1', + ptyId: 'pty-1', + status: 'ready', + terminal: TERMINAL_HANDLE, + launchAgent: 'claude', + agentStatus, + viewMode: 'chat', + isActive: true + } +} + +// Versions are per-worktree and only advance on real content change, so the +// mock can't fake a re-render heartbeat the runtime would never send. +function buildTabsResult(worktree: string): RuntimeMobileSessionTabsResult { + const result: RuntimeMobileSessionTabsResult = { + worktree, + publicationEpoch: 'mock-epoch-1', + snapshotVersion: 0, + activeGroupId: 'group-1', + activeTabId: TAB_ID, + activeTabType: 'terminal', + tabGroups: [{ id: 'group-1', activeTabId: TAB_ID, tabOrder: [TAB_ID] }], + tabs: [buildTab()] + } + const signature = JSON.stringify(result) + const previous = tabsSnapshots.get(worktree) + const current = + previous?.signature === signature + ? previous + : { version: (previous?.version ?? 0) + 1, signature } + tabsSnapshots.set(worktree, current) + result.snapshotVersion = current.version + return result +} + +function tabsResultIfChanged(worktree: string): RuntimeMobileSessionTabsResult | null { + const before = tabsSnapshots.get(worktree)?.version + const result = buildTabsResult(worktree) + return result.snapshotVersion === before ? null : result +} + +function worktreeOf(request: RpcRequest): string { + const raw = request.params?.worktree + return typeof raw === 'string' ? raw : 'id:mock-worktree' +} + +// Why: unsubscribe correlates by worktree, not request id, and a socket that +// navigates A->B->A would otherwise stack one push loop per subscribe. +const tabsPushLoops = new Map>>() + +function stopTabsPushLoop(ws: WebSocket, worktree: string): void { + const loops = tabsPushLoops.get(ws) + const existing = loops?.get(worktree) + if (existing !== undefined) { + clearInterval(existing) + loops?.delete(worktree) + } +} + +function startTabsPushLoop(ws: WebSocket, worktree: string, push: () => void): void { + stopTabsPushLoop(ws, worktree) + const interval = setInterval(() => { + if (ws.readyState !== ws.OPEN) { + stopTabsPushLoop(ws, worktree) + return + } + push() + }, 3000) + let loops = tabsPushLoops.get(ws) + if (!loops) { + loops = new Map() + tabsPushLoops.set(ws, loops) + ws.once('close', () => { + for (const timer of tabsPushLoops.get(ws)?.values() ?? []) { + clearInterval(timer) + } + tabsPushLoops.delete(ws) + }) + } + loops.set(worktree, interval) +} + +type Respond = (response: RpcResponse) => void +type Success = (id: string, result: unknown, streaming?: boolean) => RpcResponse +type Failure = (id: string, code: string, message: string) => RpcResponse + +/** Mock backend for the native-chat surface: session tabs, an empty transcript + * snapshot, terminal send, and image upload. Opt-in via MOCK_NATIVE_CHAT=1 + * because it replaces the default terminal fixtures. No transcript or terminal + * output frames are pushed. Returns false for methods it does not own. */ +export function handleMockNativeChatRequest( + request: RpcRequest, + respond: Respond, + success: Success, + error: Failure, + ws: WebSocket +): boolean { + if (process.env.MOCK_NATIVE_CHAT !== '1') { + return false + } + switch (request.method) { + case 'session.tabs.list': + respond(success(request.id, buildTabsResult(worktreeOf(request)))) + return true + + case 'session.tabs.subscribe': { + const worktree = worktreeOf(request) + respond(success(request.id, buildTabsResult(worktree), true)) + startTabsPushLoop(ws, worktree, () => { + const changed = tabsResultIfChanged(worktree) + if (changed) { + respond(success(request.id, changed, true)) + } + }) + return true + } + + case 'session.tabs.unsubscribe': + stopTabsPushLoop(ws, worktreeOf(request)) + respond(success(request.id, { ok: true })) + return true + + case 'session.tabs.activate': + case 'nativeChat.unsubscribe': + respond(success(request.id, { ok: true })) + return true + + case 'terminal.list': { + // `omit` = empty list; `other` = a live list that just doesn't name the + // chat handle (what the runtime returns when the handle is scoped to a + // different worktree id than the one mobile queries). + const mode = readControl(TERMINAL_LIST_MODE_FILE) + const worktreeId = worktreeOf(request).replace(/^id:/, '') + const entry = (handle: string) => ({ + handle, + worktreeId, + title: 'Claude Code', + isActive: true, + hasRunningProcess: true + }) + const terminals = + mode === 'omit' + ? [] + : mode === 'other' + ? [entry('some-other-term')] + : [entry(TERMINAL_HANDLE)] + respond(success(request.id, { terminals, totalCount: terminals.length, truncated: false })) + return true + } + + case 'nativeChat.subscribe': + respond(success(request.id, { type: 'snapshot', messages: [], hasMore: false }, true)) + return true + + case 'nativeChat.readSession': + respond(success(request.id, { messages: [], hasMore: false })) + return true + + case 'terminal.subscribe': { + // The `subscribed` frame is what releases mobile's native-chat input lease. + respond(success(request.id, { type: 'subscribed', terminal: TERMINAL_HANDLE }, true)) + if (readControl(TERMINAL_STREAM_MODE_FILE) === 'dead') { + respond(success(request.id, { type: 'end' })) + return true + } + respond( + success( + request.id, + { type: 'scrollback', serialized: '', cols: 80, rows: 24, seq: 1 }, + true + ) + ) + return true + } + + case 'terminal.send': { + const mode = readControl(SEND_MODE_FILE) || 'accept' + console.log(`[mock] terminal.send mode=${mode} text=${JSON.stringify(request.params?.text)}`) + if (mode === 'error') { + respond(error(request.id, 'mobile_input_floor_unavailable', 'Mobile input floor is held')) + return true + } + respond( + success(request.id, { + send: { + handle: TERMINAL_HANDLE, + accepted: mode === 'accept', + bytesWritten: mode === 'accept' ? String(request.params?.text ?? '').length : 0 + } + }) + ) + return true + } + + case 'clipboard.startImageUpload': + respond(success(request.id, { uploadId: 'mock-upload-1' })) + return true + + case 'clipboard.appendImageUploadChunk': + respond(success(request.id, { ok: true })) + return true + + case 'clipboard.commitImageUpload': + case 'clipboard.saveImageAsTempFile': + respond(success(request.id, MOCK_IMAGE_PATH)) + return true + + case 'clipboard.abortImageUpload': + respond(success(request.id, { ok: true })) + return true + + default: + return false + } +} diff --git a/mobile/scripts/mock-server-rpc-handlers.ts b/mobile/scripts/mock-server-rpc-handlers.ts index ccb8c1c721f..d704c79e855 100644 --- a/mobile/scripts/mock-server-rpc-handlers.ts +++ b/mobile/scripts/mock-server-rpc-handlers.ts @@ -11,6 +11,7 @@ import type { TerminalQuickCommand } from '../../src/shared/types' import { handleMockFilePreviewRequest } from './mock-server-file-preview-data' import { handleMockGitRequest } from './mock-server-git-state' import { handleMockAccountRequest } from './mock-server-account-rpc' +import { handleMockNativeChatRequest } from './mock-server-native-chat-scenario' import { createMockTerminals, FAKE_SCROLLBACK, @@ -118,13 +119,13 @@ export function handleRequest( send(response) } - if (handleMockGitRequest(request, respond, success)) { - return - } - if (handleMockFilePreviewRequest(request, respond, success, error)) { - return - } - if (handleMockAccountRequest(request, respond, success, error)) { + // Each returns false for methods it does not own; first owner wins. + if ( + handleMockGitRequest(request, respond, success) || + handleMockFilePreviewRequest(request, respond, success, error) || + handleMockAccountRequest(request, respond, success, error) || + handleMockNativeChatRequest(request, respond, success, error, ws) + ) { return } diff --git a/mobile/scripts/mock-server.ts b/mobile/scripts/mock-server.ts index 26bc5be1db6..5298a014336 100644 --- a/mobile/scripts/mock-server.ts +++ b/mobile/scripts/mock-server.ts @@ -3,8 +3,8 @@ // a running Orca desktop instance. Responds to the same RPC methods the real // runtime exposes, with realistic fake data. Supports E2EE handshake. import { WebSocketServer, type WebSocket } from 'ws' -import nacl from 'tweetnacl' import { deriveSharedKey, e2eeDecrypt, e2eeEncrypt, type E2EEState } from './mock-server-encryption' +import { loadOrCreateMockServerKeyPair } from './mock-server-key-pair' import { error, handleRequest, @@ -17,7 +17,9 @@ const AUTH_TOKEN = 'mock-device-token' // Why: generate a persistent server keypair for this mock session. // The public key is printed at startup so it can be used in pairing QR data. -const serverKeyPair = nacl.box.keyPair() +// MOCK_SERVER_KEY_FILE reuses one across restarts so a paired device (which +// pins the public key) survives a server restart. +const serverKeyPair = loadOrCreateMockServerKeyPair(process.env.MOCK_SERVER_KEY_FILE) const serverPublicKeyB64 = Buffer.from(serverKeyPair.publicKey).toString('base64') const wss = new WebSocketServer({ port: PORT }) diff --git a/mobile/scripts/start-emulator-pairing-runtime.mjs b/mobile/scripts/start-emulator-pairing-runtime.mjs index 02078825db4..40188efacbf 100644 --- a/mobile/scripts/start-emulator-pairing-runtime.mjs +++ b/mobile/scripts/start-emulator-pairing-runtime.mjs @@ -85,7 +85,11 @@ async function waitForPairingRuntime({ child, userData, pairingAddress, logSucce process: child, env: { ...process.env, - ORCA_USER_DATA_PATH: userData + ORCA_USER_DATA_PATH: userData, + // Why: `orca-dev` derives its own profile and ignores ORCA_USER_DATA_PATH, so + // without this an ORCA_CLI=orca-dev run would address the dev profile instead + // of this disposable runtime. Plain `orca` ignores it. + ORCA_DEV_USER_DATA_PATH: userData }, stop }) diff --git a/mobile/src/mock-server-key-pair.test.ts b/mobile/src/mock-server-key-pair.test.ts new file mode 100644 index 00000000000..bf4f764dd3c --- /dev/null +++ b/mobile/src/mock-server-key-pair.test.ts @@ -0,0 +1,209 @@ +import { spawn } from 'node:child_process' +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { pathToFileURL } from 'node:url' +import nacl from 'tweetnacl' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { loadOrCreateMockServerKeyPair } from '../scripts/mock-server-key-pair' + +const temporaryDirectories: string[] = [] + +type ConcurrentCreator = { + ready: Promise + calling: Promise + start: () => void + stop: () => void + closed: Promise + result: Promise +} + +function keyFilePath(): string { + const directory = mkdtempSync(join(tmpdir(), 'orca-mock-key-')) + temporaryDirectories.push(directory) + return join(directory, 'server-key') +} + +function runConcurrentCreator(keyFile: string): ConcurrentCreator { + const moduleUrl = pathToFileURL( + join(import.meta.dirname, '../scripts/mock-server-key-pair.ts') + ).href + const script = ` + const { loadOrCreateMockServerKeyPair } = await import(process.argv[1]) + process.stdout.write('READY\\n') + await new Promise((resolve) => process.stdin.once('data', resolve)) + process.stdout.write('CALLING\\n') + const keyPair = loadOrCreateMockServerKeyPair(process.argv[2], { warn() {} }) + process.stdout.write('KEY:' + Buffer.from(keyPair.secretKey).toString('base64') + '\\n') + ` + const child = spawn( + process.execPath, + ['--import', 'tsx', '--input-type=module', '--eval', script, moduleUrl, keyFile], + { stdio: ['pipe', 'pipe', 'pipe'] } + ) + let stdout = '' + let stderr = '' + let resolveReady!: () => void + let rejectReady!: (error: Error) => void + let resolveCalling!: () => void + let rejectCalling!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const calling = new Promise((resolve, reject) => { + resolveCalling = resolve + rejectCalling = reject + }) + let resolveClosed!: () => void + const closed = new Promise((resolve) => { + resolveClosed = resolve + }) + const timeout = setTimeout(() => child.kill(), 5_000) + timeout.unref() + child.stdout.on('data', (chunk) => { + stdout += chunk.toString() + if (stdout.includes('READY\n')) { + resolveReady() + } + if (stdout.includes('CALLING\n')) { + resolveCalling() + } + }) + child.stderr.on('data', (chunk) => { + stderr += chunk.toString() + }) + const result = new Promise((resolve, reject) => { + child.on('error', (error) => { + rejectReady(error) + rejectCalling(error) + reject(error) + }) + child.on('close', (code) => { + clearTimeout(timeout) + resolveClosed() + const error = new Error(stderr || `Concurrent key creator exited ${code}`) + if (!stdout.includes('READY\n')) { + rejectReady(error) + } + if (!stdout.includes('CALLING\n')) { + rejectCalling(error) + } + const key = stdout.match(/KEY:([A-Za-z0-9+/=]+)\n/)?.[1] + if (code === 0 && key) { + resolve(key) + } else { + reject(error) + } + }) + }) + void result.catch(() => {}) + return { + ready, + calling, + start: () => { + if (!child.stdin.destroyed && !child.stdin.writableEnded) { + child.stdin.end('go\n') + } + }, + stop: () => { + if (child.exitCode === null && child.signalCode === null) { + child.kill() + } + }, + closed, + result + } +} + +async function cleanupConcurrentCreators( + creators: ConcurrentCreator[], + lockFile: string, + removeLock: boolean +): Promise { + let lockRemovalError: unknown + if (removeLock) { + try { + rmSync(lockFile, { force: true }) + } catch (error) { + lockRemovalError = error + } + } + creators.forEach((creator) => { + creator.start() + creator.stop() + }) + await Promise.allSettled(creators.flatMap((creator) => [creator.result, creator.closed])) + if (lockRemovalError) { + throw lockRemovalError + } +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('mock server key persistence', () => { + it('persists one private key and reuses it after restart', () => { + const keyFile = keyFilePath() + const first = loadOrCreateMockServerKeyPair(keyFile, { warn: vi.fn() }) + const second = loadOrCreateMockServerKeyPair(keyFile) + + expect(second.secretKey).toEqual(first.secretKey) + expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(first.secretKey).toString('base64')) + expect(readdirSync(dirname(keyFile))).toEqual(['server-key']) + if (process.platform !== 'win32') { + expect(statSync(keyFile).mode & 0o777).toBe(0o600) + } + }) + + it('re-keys canonical-length content with malformed base64', () => { + const keyFile = keyFilePath() + const encoded = Buffer.from(nacl.box.keyPair().secretKey).toString('base64') + const malformed = `${encoded.slice(0, 4)}!${encoded.slice(4)}` + expect(Buffer.from(malformed, 'base64')).toHaveLength(nacl.box.secretKeyLength) + writeFileSync(keyFile, malformed) + const logger = { warn: vi.fn() } + + const loaded = loadOrCreateMockServerKeyPair(keyFile, logger) + + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('invalid base64')) + expect(readFileSync(keyFile, 'utf-8')).toBe(Buffer.from(loaded.secretKey).toString('base64')) + }) + + it('makes concurrent creators converge on the persisted winner', async () => { + const keyFile = keyFilePath() + const lockFile = `${keyFile}.lock` + writeFileSync(lockFile, '', { flag: 'wx', mode: 0o600 }) + const firstCreator = runConcurrentCreator(keyFile) + const secondCreator = runConcurrentCreator(keyFile) + const creators = [firstCreator, secondCreator] + let parentOwnsLock = true + try { + await Promise.all(creators.map((creator) => creator.ready)) + creators.forEach((creator) => creator.start()) + await Promise.all(creators.map((creator) => creator.calling)) + await new Promise((resolve) => setTimeout(resolve, 50)) + rmSync(lockFile) + parentOwnsLock = false + const [first, second] = await Promise.all(creators.map((creator) => creator.result)) + + expect(second).toBe(first) + expect(readFileSync(keyFile, 'utf-8')).toBe(first) + expect(readdirSync(dirname(keyFile))).toEqual(['server-key']) + } finally { + await cleanupConcurrentCreators(creators, lockFile, parentOwnsLock) + } + }) + + it('does not overwrite an invalid key owned by another creator', () => { + const keyFile = keyFilePath() + writeFileSync(keyFile, 'invalid') + writeFileSync(`${keyFile}.lock`, '', { flag: 'wx', mode: 0o600 }) + + expect(() => loadOrCreateMockServerKeyPair(keyFile)).toThrow('remained busy') + expect(readFileSync(keyFile, 'utf-8')).toBe('invalid') + }) +}) diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 36006d2f633..4cbc955a9f4 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -114,6 +114,31 @@ describe('MobileNativeChatComposer', () => { expect(onSend).not.toHaveBeenCalled() }) + it('keeps the text input editable while the send is locked', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: 'half-typed', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + disabled: true + }) + ) + }) + } finally { + restore() + } + // Revoking `editable` on a focused field resigns first responder on iOS and + // yanks the keyboard mid-typing (#10681) — the lock may only gate sending. + const input = renderer!.root.find((node) => node.type === 'TextInput') as { + props: { editable?: boolean } + } + expect(input.props.editable).not.toBe(false) + expect(sendButton().props).toMatchObject({ disabled: true }) + }) + it('renders a removable thumbnail for each pending image attachment', async () => { const onRemoveAttachment = vi.fn() const restore = suppressRendererWarning() diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index 58508be77b5..202a0ae5398 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -215,7 +215,9 @@ export function MobileNativeChatComposer({ placeholderTextColor={colors.textMuted} selectionColor={colors.accentBlue} multiline - editable={!disabled} + // Why: never revoke `editable` — iOS resigns first responder on a focused + // field, so a transient lock would yank the keyboard mid-typing (#10681). + // The lock gates sending; the draft survives and rides the next send. textAlignVertical="top" /> {onMicPress ? ( diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 6277ed1a69b..b8f9cd9277b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -14,6 +14,10 @@ type Props = { onMicPressIn: () => void onMicPressOut: () => void inputLockReason: MobileNativeChatInputLockReason | null + /** Latest send failure, rendered inline above the composer. */ + sendErrorMessage: string | null + /** Drops that failure once a later send succeeds. */ + onClearSendError: () => void keyboardInset: number } @@ -28,6 +32,8 @@ export function MobileNativeChatOverlay({ onMicPressIn, onMicPressOut, inputLockReason, + sendErrorMessage, + onClearSendError, keyboardInset }: Props): React.JSX.Element | null { if (!controller.showNativeChat) { @@ -69,6 +75,8 @@ export function MobileNativeChatOverlay({ onMicPressIn={onMicPressIn} onMicPressOut={onMicPressOut} inputLockReason={inputLockReason} + sendErrorMessage={sendErrorMessage} + onClearSendError={onClearSendError} filePaths={controller.nativeChatFilePaths} onNeedFiles={controller.loadNativeChatFiles} keyboardInset={keyboardInset} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts new file mode 100644 index 00000000000..86ead424b90 --- /dev/null +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -0,0 +1,164 @@ +import { createElement } from 'react' +import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MobileNativeChatView } from './MobileNativeChatView' + +vi.mock('react-native', () => ({ + ActivityIndicator: 'ActivityIndicator', + FlatList: 'FlatList', + Pressable: 'Pressable', + StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }, + Text: 'Text', + View: 'View' +})) + +vi.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }) +})) + +vi.mock('react-native-gesture-handler', () => { + const chain = { + runOnJS: () => chain, + onStart: () => chain, + onUpdate: () => chain + } + return { + Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain }, + GestureDetector: 'GestureDetector', + GestureHandlerRootView: 'GestureHandlerRootView' + } +}) + +vi.mock('lucide-react-native', () => ({ + ArrowDown: 'ArrowDown', + ChevronsDownUp: 'ChevronsDownUp', + ChevronsUpDown: 'ChevronsUpDown', + Square: 'Square' +})) + +vi.mock('./MobileNativeChatMessage', () => ({ MobileNativeChatMessage: 'ChatMessage' })) +vi.mock('./MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' })) +vi.mock('./MobileNativeChatPermission', () => ({ MobileNativeChatPermission: 'ChatPermission' })) +vi.mock('./MobileNativeChatQuestion', () => ({ MobileNativeChatQuestion: 'ChatQuestion' })) +vi.mock('./MobileAgentWorkingIndicator', () => ({ + MobileAgentWorkingIndicator: 'WorkingIndicator' +})) + +// Stand-in composer: exposes the view's `handleSend` through a pressable, which is +// the only composer behaviour these banner tests exercise. +vi.mock('./MobileNativeChatComposer', async () => { + const React = await import('react') + return { + MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + React.createElement('Composer', { + accessibilityLabel: 'Send message', + onPress: () => props.onSend('hi') + }) + } +}) + +type Overrides = { + sendErrorMessage?: string | null + onClearSendError?: () => void + inputLockReason?: 'disconnected' | 'waiting' | null + onSend?: (text: string) => Promise +} + +function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() +} + +describe('MobileNativeChatView send-error banner', () => { + let renderer: ReactTestRenderer | null = null + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + }) + + async function render(overrides: Overrides = {}): Promise { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create( + createElement(MobileNativeChatView, { + messages: [], + status: 'ready', + onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true), + pending: [], + composerText: '', + onComposerTextChange: vi.fn(), + ...overrides + }) + ) + }) + } finally { + restore() + } + } + + function banners(): ReactTestInstance[] { + return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') + } + + function bannerText(): string { + const [alert, ...rest] = banners() + expect(rest).toHaveLength(0) + return alert + .findAll((node) => node.type === 'Text') + .map((node) => node.props.children) + .join('') + } + + async function pressSend(): Promise { + const composer = renderer!.root.find((node) => node.type === 'Composer') as { + props: { onPress: () => Promise } + } + await act(async () => { + await composer.props.onPress() + }) + } + + it('renders the route-reported failure verbatim', async () => { + await render({ sendErrorMessage: 'Permission reply failed' }) + + expect(banners()).toHaveLength(1) + expect(bannerText()).toContain('Permission reply failed') + }) + + it('does not duplicate the route banner when the composer rejects', async () => { + const onClearSendError = vi.fn() + await render({ + onSend: vi.fn().mockResolvedValue(false), + inputLockReason: 'disconnected', + sendErrorMessage: 'Stop failed', + onClearSendError + }) + await pressSend() + + expect(onClearSendError).not.toHaveBeenCalled() + expect(banners()).toHaveLength(1) + expect(bannerText()).toContain('Stop failed') + expect(bannerText()).toBe('Stop failed') + }) + + it('retires the route-owned banner once a send is accepted', async () => { + const onClearSendError = vi.fn() + await render({ sendErrorMessage: 'Stop failed', onClearSendError }) + + await pressSend() + + expect(onClearSendError).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index f2a23910a26..f14efc1f25f 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -71,6 +71,13 @@ type Props = { onMicPressIn?: () => void onMicPressOut?: () => void inputLockReason?: MobileNativeChatInputLockReason | null + /** Route-reported send failure (answer cards, permission replies, stop). Shares the + * inline banner with a rejected composer send, so one failure paints once. The + * route routes these here only while this view is mounted, and falls back to its + * toast otherwise — a deferred failure must not land on an unmounted banner. */ + sendErrorMessage?: string | null + /** Clears `sendErrorMessage` once a later send is accepted. */ + onClearSendError?: () => void filePaths?: string[] onNeedFiles?: (query: string) => void /** A pending agent question/permission detected from live status, shown as a @@ -118,6 +125,8 @@ export function MobileNativeChatView({ onMicPressIn, onMicPressOut, inputLockReason, + sendErrorMessage, + onClearSendError, filePaths, onNeedFiles, ask, @@ -143,17 +152,6 @@ export function MobileNativeChatView({ const [atBottom, setAtBottom] = useState(true) const sendScrollTimerRef = useRef | null>(null) const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture() - // Surface a rejected send inline above the composer — a bottom toast gets hidden - // behind the keyboard (the case that prompted this). Auto-dismisses after a beat. - const [sendFailed, setSendFailed] = useState(false) - useEffect(() => { - if (!sendFailed) { - return - } - const t = setTimeout(() => setSendFailed(false), 4000) - return () => clearTimeout(t) - }, [sendFailed]) - useEffect( () => () => { if (sendScrollTimerRef.current) { @@ -189,10 +187,11 @@ export function MobileNativeChatView({ async (text: string): Promise => { const accepted = await onSend(text) if (!accepted) { - setSendFailed(true) return false } - setSendFailed(false) + // The route-owned banner outlives this send; a success must retire it too, + // or a stale "Message not sent" sits above the delivered message. + onClearSendError?.() // Always jump to the newest message when the user sends. setAtBottom(true) if (sendScrollTimerRef.current) { @@ -204,7 +203,7 @@ export function MobileNativeChatView({ }, 60) return true }, - [onSend] + [onSend, onClearSendError] ) const onScroll = useCallback( @@ -397,13 +396,14 @@ export function MobileNativeChatView({ ) : null} - {sendFailed ? ( - - - {rawLockReason === 'disconnected' - ? 'Message not sent — reconnecting…' - : 'Message not sent'} - + {sendErrorMessage ? ( + // This banner is the only channel for a send failure — announce it. + + {sendErrorMessage} ) : null} { expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[0]?.params).not.toHaveProperty('client') }) + + it('aborts rather than scheduling a write past the shared paste deadline', async () => { + vi.useFakeTimers() + try { + const responses = [sendResult(true), sendResult(true), sendResult(true)] + const calls: { timeoutMs: unknown }[] = [] + // Each write burns 10s, so the 15s sequence budget is spent by the third. + const client = { + sendRequest: vi.fn(async (_method: string, _params?: unknown, options?: unknown) => { + calls.push({ timeoutMs: (options as { timeoutMs: number }).timeoutMs }) + vi.advanceTimersByTime(10_000) + return responses.shift()! + }) + } + + const ok = await pasteMobileNativeChatImagePaths({ + client, + terminal: 'term-1', + deviceToken: null, + imagePaths: ['/tmp/a.png', '/tmp/b.png'] + }) + + expect(ok).toBe(false) + // Clear + first image only; the second image is never written. + expect(calls).toHaveLength(2) + expect(calls[0]?.timeoutMs).toBe(15_000) + // Positive-but-small remainder still gets the floor. + expect(calls[1]?.timeoutMs).toBe(5_000) + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/mobile-native-chat-image-send.ts b/mobile/src/session/mobile-native-chat-image-send.ts index 6d163b41fc3..4d52c59b0bb 100644 --- a/mobile/src/session/mobile-native-chat-image-send.ts +++ b/mobile/src/session/mobile-native-chat-image-send.ts @@ -1,5 +1,9 @@ import type { RpcClient } from '../transport/rpc-client' import { buildMobileImagePastePayload } from './mobile-clipboard-image' +import { + MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS, + openMobileNativeChatSendBudget +} from './mobile-native-chat-send' import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' // Give the agent TUI a beat to register each bracketed image paste before the @@ -19,6 +23,9 @@ type PasteImagesArgs = { readonly terminal: string readonly deviceToken: string | null readonly imagePaths: readonly string[] + /** Budget shared with the rest of the user action (the text body that follows, or + * the send this is healing for). Omit to open a fresh one for this paste alone. */ + readonly deadline?: number } /** Clears the agent's unsubmitted input line, then pastes each uploaded image @@ -30,22 +37,40 @@ export async function pasteMobileNativeChatImagePaths({ client, terminal, deviceToken, - imagePaths + imagePaths, + deadline: sharedDeadline }: PasteImagesArgs): Promise { const mobileClient: MobileTerminalClient | null = deviceToken ? { id: deviceToken, type: 'mobile' } : null const clientField = mobileClient ? { client: mobileClient } : {} + // Why: this is a sequential loop, so a per-write budget multiplies by the number + // of images — the composer stays `sending` the whole time. Budget the sequence + // once and let each write draw from what's left. + const deadline = sharedDeadline ?? openMobileNativeChatSendBudget() for (const text of [ MOBILE_NATIVE_CHAT_CLEAR_UNSUBMITTED_INPUT, ...imagePaths.map(buildMobileImagePastePayload) ]) { - const response = await client.sendRequest('terminal.send', { - terminal, - text, - enter: false, - ...clientField - }) + const remainingMs = deadline - Date.now() + // Why: the budget is the whole sequence's — starting a write it can't fund would + // let a multi-image paste overrun before the text body even begins its own send. + // Abort instead; the caller reports the failure and can retry. + if (remainingMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { + return false + } + const response = await client.sendRequest( + 'terminal.send', + { + terminal, + text, + enter: false, + ...clientField + }, + // The remaining budget covers the reconnect wait too; a fresh post-connect + // clock here would let one write outlast the whole sequence's ceiling. + { timeoutMs: remainingMs, budgetSpansConnect: true } + ) if (!isTerminalSendRpcAccepted(response)) { return false } diff --git a/mobile/src/session/mobile-native-chat-permission-send.test.ts b/mobile/src/session/mobile-native-chat-permission-send.test.ts index b2db8580937..1b595683112 100644 --- a/mobile/src/session/mobile-native-chat-permission-send.test.ts +++ b/mobile/src/session/mobile-native-chat-permission-send.test.ts @@ -3,6 +3,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS } from './mobile-native-chat-send' import { sendMobileNativeChatPermissionResponse, useMobileNativeChatPermissionSend @@ -28,12 +29,16 @@ describe('sendMobileNativeChatPermissionResponse', () => { text: '1' }) ).resolves.toBe('accepted') - expect(sendRequest).toHaveBeenCalledWith('terminal.send', { - terminal: 'terminal', - text: '1', - enter: false, - client: { id: 'phone', type: 'mobile' } - }) + expect(sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'terminal', + text: '1', + enter: false, + client: { id: 'phone', type: 'mobile' } + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) }) it('surfaces an ambiguous delivery as unknown instead of a definite failure', async () => { diff --git a/mobile/src/session/mobile-native-chat-send.test.ts b/mobile/src/session/mobile-native-chat-send.test.ts index 5384436ac1f..2f4e5fb9b8d 100644 --- a/mobile/src/session/mobile-native-chat-send.test.ts +++ b/mobile/src/session/mobile-native-chat-send.test.ts @@ -3,6 +3,8 @@ import type { RpcClient } from '../transport/rpc-client' import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' import { LogicalClientCutoverError } from '../transport/stable-logical-rpc-client' import { + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, + openMobileNativeChatSendBudget, sendMobileNativeChatMessage, sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' @@ -30,12 +32,16 @@ describe('sendMobileNativeChatMessage', () => { mobileClient: { id: 'device', type: 'mobile' } }) ).resolves.toBe(true) - expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { - terminal: 'term', - text: 'hello', - enter: true, - client: { id: 'device', type: 'mobile' } - }) + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: 'hello', + enter: true, + client: { id: 'device', type: 'mobile' } + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) }) it('returns false when the terminal rejects the send', async () => { @@ -87,6 +93,34 @@ describe('sendMobileNativeChatMessage', () => { ).resolves.toBe(false) }) + it('reports an unknown outcome when the request timeout expires', async () => { + // The request-timeout path: the frame was written and only the ack is missing, + // so calling it "Message not sent" would hide a message the desktop received. + const client = { + sendRequest: vi + .fn() + .mockRejectedValue(markRpcDeliveryUnknown(new Error('Request timed out: terminal.send'))) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('unknown') + }) + + it('reports a definite rejection when the connect wait times out', async () => { + // The same timeout budget also covers the pre-connect wait, which is deliberately + // NOT marked delivery-unknown — no frame was ever written. + const client = { + sendRequest: vi + .fn() + .mockRejectedValue(new Error('Timed out while connecting to the remote Orca runtime.')) + } as unknown as RpcClient + + await expect( + sendMobileNativeChatMessageWithOutcome({ client, terminal: 'term', text: 'hello' }) + ).resolves.toBe('rejected') + }) + it('reports an unknown outcome when a logical cutover interrupts the send', async () => { const client = { sendRequest: vi.fn().mockRejectedValue(new LogicalClientCutoverError()) @@ -149,10 +183,59 @@ describe('sendMobileNativeChatMessage', () => { text: String.fromCharCode(27), enter: false }) - expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', { - terminal: 'term', - text: String.fromCharCode(27), - enter: false + expect(client.sendRequest).toHaveBeenCalledWith( + 'terminal.send', + { + terminal: 'term', + text: String.fromCharCode(27), + enter: false + }, + { timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, budgetSpansConnect: true } + ) + }) + + it('spends only what is left of a shared budget', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } }) + + await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: 'term', + text: 'hi', + deadline: Date.now() + 4_000 + }) + const options = vi.mocked(client.sendRequest).mock.calls[0]?.[2] as { timeoutMs: number } + expect(options.timeoutMs).toBeGreaterThan(3_000) + expect(options.timeoutMs).toBeLessThanOrEqual(4_000) + }) + + it('refuses a write whose shared budget cannot fund the final acknowledgement', async () => { + const client = clientWithResponse({ + id: 'request', + ok: true, + result: { send: { accepted: true } }, + _meta: { runtimeId: 'runtime' } + }) + + // Nothing reaches the wire, so this is a definite non-send rather than ambiguous. + await expect( + sendMobileNativeChatMessageWithOutcome({ + client, + terminal: 'term', + text: 'hi', + deadline: Date.now() + 400 + }) + ).resolves.toBe('rejected') + expect(client.sendRequest).not.toHaveBeenCalled() + }) + + it('opens a budget bounded by the send timeout', () => { + const budget = openMobileNativeChatSendBudget() - Date.now() + expect(budget).toBeGreaterThan(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS - 1_000) + expect(budget).toBeLessThanOrEqual(MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS) }) }) diff --git a/mobile/src/session/mobile-native-chat-send.ts b/mobile/src/session/mobile-native-chat-send.ts index 0e20931abcd..6bce66e9bfa 100644 --- a/mobile/src/session/mobile-native-chat-send.ts +++ b/mobile/src/session/mobile-native-chat-send.ts @@ -14,6 +14,9 @@ type MobileNativeChatSendArgs = { text: string enter?: boolean mobileClient?: MobileTerminalClient + /** Shared budget for a whole user action (heal → paste → text, or one selector's + * keystroke sequence). Omit to give this write its own full budget. */ + deadline?: number } /** 'unknown' = the RPC failed without proof the request never reached the @@ -21,16 +24,42 @@ type MobileNativeChatSendArgs = { * frame was written) — callers must not present it as a definite send failure. */ export type MobileNativeChatSendOutcome = 'accepted' | 'rejected' | 'unknown' +/** Without an explicit timeout `sendRequest` waits for reconnect indefinitely, and + * the composer holds `sending` (send arrow dimmed, no error) for as long as it + * pends. Chat writes are interactive: fail them so the user can retry. */ +export const MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS = 15_000 +export const MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS = 2_000 + +/** Opens a budget for one user action. Multi-write actions (heal → paste → text, a + * paced selector answer) must share one so the composer's `sending` window stays + * bounded by MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS instead of multiplying by it. */ +export function openMobileNativeChatSendBudget(): number { + return Date.now() + MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS +} + export async function sendMobileNativeChatMessageWithOutcome( args: MobileNativeChatSendArgs ): Promise { + const timeoutMs = + args.deadline === undefined ? MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS : args.deadline - Date.now() + // Starting an underfunded final write risks delivery followed by a false timeout. + if (timeoutMs < MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS) { + return 'rejected' + } try { - const response = await args.client.sendRequest('terminal.send', { - terminal: args.terminal, - text: args.text, - enter: args.enter ?? true, - ...(args.mobileClient ? { client: args.mobileClient } : {}) - }) + const response = await args.client.sendRequest( + 'terminal.send', + { + terminal: args.terminal, + text: args.text, + enter: args.enter ?? true, + ...(args.mobileClient ? { client: args.mobileClient } : {}) + }, + // The budget covers this whole write, reconnect wait included — a chat send + // that spends its ceiling waiting to connect and then starts a fresh clock + // pins the composer for twice as long. + { timeoutMs, budgetSpansConnect: true } + ) return isTerminalSendRpcAccepted(response) ? 'accepted' : 'rejected' } catch (error) { // Why: a logical relay↔direct cutover rejects the in-flight send without diff --git a/mobile/src/session/mobile-native-chat-stale-input.ts b/mobile/src/session/mobile-native-chat-stale-input.ts index 8fcc0b9a514..18d84d2e503 100644 --- a/mobile/src/session/mobile-native-chat-stale-input.ts +++ b/mobile/src/session/mobile-native-chat-stale-input.ts @@ -41,6 +41,9 @@ export async function healMobileNativeChatStaleInput(args: { readonly client: Pick readonly terminal: string readonly deviceToken: string | null + /** Budget shared with the write this heal precedes, so a hung clear can't spend a + * full send timeout and leave the following write free to spend another. */ + readonly deadline?: number }): Promise { if (!isMobileNativeChatInputStale(args.terminal)) { return true @@ -51,7 +54,8 @@ export async function healMobileNativeChatStaleInput(args: { client: args.client, terminal: args.terminal, deviceToken: args.deviceToken, - imagePaths: [] + imagePaths: [], + ...(args.deadline === undefined ? {} : { deadline: args.deadline }) }) } catch { // Leave marked for the next attempt. diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts index 9b751c5af75..df860efc6b1 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.test.ts @@ -80,13 +80,26 @@ describe('mobile native-chat terminal stream lifecycle', () => { ) }) + it('rearms a covered stream that lost its subscription', () => { + // The covered stream IS the input lease, and nothing else re-subscribes it — + // losing it while chat is open must not leave the composer locked (#10681). + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + showNativeChat: true, + streamActive: false, + streamCovered: true + }) + ).toBe('rearm') + }) + it('does nothing for non-terminal tabs, missing handles, or settled states', () => { expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none') expect( resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true, - streamActive: false, + streamActive: true, streamCovered: true }) ).toBe('none') @@ -96,5 +109,13 @@ describe('mobile native-chat terminal stream lifecycle', () => { expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe( 'none' ) + // Leaving chat with the WebView not yet ready must wait, not resume blind. + expect( + resolveMobileNativeChatTerminalStreamAction({ + ...base, + streamCovered: true, + webViewReady: false + }) + ).toBe('none') }) }) diff --git a/mobile/src/session/mobile-native-chat-terminal-stream.ts b/mobile/src/session/mobile-native-chat-terminal-stream.ts index d96ced2a6f6..b9b58448113 100644 --- a/mobile/src/session/mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/mobile-native-chat-terminal-stream.ts @@ -1,4 +1,4 @@ -export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none' +export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'rearm' | 'none' /** Decides whether the active mobile terminal stream should run while native chat * covers its WebView. Resume is allowed only once the mounted WebView is ready. */ @@ -14,7 +14,13 @@ export function resolveMobileNativeChatTerminalStreamAction(args: { return 'none' } if (args.showNativeChat) { - return !args.streamCovered ? 'pause' : 'none' + if (!args.streamCovered) { + return 'pause' + } + // Why: the covered stream IS the input lease. Anything that tore it down + // (terminal.list churn, a client swap, an `end` frame) would otherwise leave + // the composer locked forever — nothing else re-subscribes a covered handle. + return args.streamActive ? 'none' : 'rearm' } return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none' } diff --git a/mobile/src/session/mobile-terminal-prune-decision.test.ts b/mobile/src/session/mobile-terminal-prune-decision.test.ts new file mode 100644 index 00000000000..c4340279f23 --- /dev/null +++ b/mobile/src/session/mobile-terminal-prune-decision.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { + resolveRetainedTerminalHandles, + shouldPruneTerminalHandle +} from './mobile-terminal-prune-decision' + +describe('shouldPruneTerminalHandle', () => { + it('prunes a handle the list no longer reports while chat is closed', () => { + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(['term-2']), + showNativeChat: false, + activeHandle: 'term-1' + }) + ).toBe(true) + }) + + it('retains the chat-covered handle when the list omits it (#10681)', () => { + // terminal.list drops every handle while the desktop graph reloads; the covered + // stream is the input lease and nothing else re-subscribes it. + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(), + showNativeChat: true, + activeHandle: 'term-1' + }) + ).toBe(false) + }) + + it('still prunes an absent handle that chat is not covering', () => { + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(['term-2']), + showNativeChat: true, + activeHandle: 'term-2' + }) + ).toBe(true) + }) + + it('keeps a handle the list still reports, whatever chat is doing', () => { + for (const showNativeChat of [true, false]) { + expect( + shouldPruneTerminalHandle({ + handle: 'term-1', + liveHandles: new Set(['term-1']), + showNativeChat, + activeHandle: null + }) + ).toBe(false) + } + }) +}) + +describe('resolveRetainedTerminalHandles', () => { + it('carries the chat-covered handle so its live-input preference survives', () => { + // Sweeping preferences against the raw list would erase the buffered-mode + // opt-out on the very refresh the subscription was retained through. + expect([ + ...resolveRetainedTerminalHandles({ + liveHandles: new Set(['term-2']), + showNativeChat: true, + activeHandle: 'term-1' + }) + ]).toEqual(['term-2', 'term-1']) + }) + + it('returns the list untouched when nothing is retained beyond it', () => { + const liveHandles = new Set(['term-1']) + expect( + resolveRetainedTerminalHandles({ + liveHandles, + showNativeChat: false, + activeHandle: 'term-2' + }) + ).toBe(liveHandles) + }) +}) diff --git a/mobile/src/session/mobile-terminal-prune-decision.ts b/mobile/src/session/mobile-terminal-prune-decision.ts new file mode 100644 index 00000000000..2ee18cfacf0 --- /dev/null +++ b/mobile/src/session/mobile-terminal-prune-decision.ts @@ -0,0 +1,64 @@ +/** Whether a known terminal handle should be dropped after a `terminal.list` refresh. + * + * A handle covered by native chat is retained even when the list omits it: the list + * drops every handle while the desktop graph reloads (it re-mints handle ids), and + * the covered stream IS the input lease — nothing else re-subscribes it, so dropping + * it there locks the composer for good (#10681). A genuinely dead PTY still arrives + * as an `end`/`error` stream frame, and the chat stream hook bounds its rearms — + * a later list that reports the handle again refills that rearm budget, so an + * exhausted rearm never locks the composer past the host's recovery. */ +export function shouldPruneTerminalHandle(args: { + handle: string + liveHandles: ReadonlySet + showNativeChat: boolean + activeHandle: string | null +}): boolean { + if (args.liveHandles.has(args.handle)) { + return false + } + return !(args.showNativeChat && args.handle === args.activeHandle) +} + +/** Binds one `terminal.list` refresh's context so callers can test many handles. */ +export function createTerminalPrunePredicate(context: { + liveHandles: ReadonlySet + showNativeChat: boolean + activeHandle: string | null +}): (handle: string) => boolean { + return (handle) => shouldPruneTerminalHandle({ handle, ...context }) +} + +/** The handles this refresh treats as alive: everything the list reported, plus + * whatever the chat retention keeps. Per-handle preferences must be swept with the + * same set the subscriptions are, or the refresh that retains a covered handle + * still erases its live-input opt-out. */ +export function resolveRetainedTerminalHandles(context: { + liveHandles: ReadonlySet + showNativeChat: boolean + activeHandle: string | null +}): ReadonlySet { + const { activeHandle } = context + if (!activeHandle || shouldPruneTerminalHandle({ handle: activeHandle, ...context })) { + return context.liveHandles + } + return new Set(context.liveHandles).add(activeHandle) +} + +/** Drops per-handle keyboard metrics for pruned terminals, returning `previous` + * untouched when nothing changed. Swept over the whole map rather than the handles + * the caller just tore down: a handle retained for chat leaves the subscription map + * without ever being revisited by that loop. */ +export function pruneTerminalKeyboardMetrics( + previous: Map, + shouldPrune: (handle: string) => boolean +): Map { + let next: Map | null = null + for (const handle of previous.keys()) { + if (!shouldPrune(handle)) { + continue + } + next ??= new Map(previous) + next.delete(handle) + } + return next ?? previous +} diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts index a9871802b5e..8f22884145e 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.test.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.test.ts @@ -164,6 +164,35 @@ describe('useMobileNativeChatAnswerSend', () => { ]) }) + it('bounds a stepped answer with one shared budget, crediting back the pacing waits', async () => { + const timeouts: number[] = [] + const sendRequest = vi.fn(async (_method: string, _params?: unknown, options?: unknown) => { + timeouts.push((options as { timeoutMs: number }).timeoutMs) + // A slow write must eat into what the rest of the answer has left. + vi.advanceTimersByTime(6_000) + return acceptedResponse() + }) + await mount({ sendRequest } as unknown as RpcClient, vi.fn()) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS)) + + await expect(result).resolves.toBe(true) + // 15s total transport, minus 6s per completed write; the 1s pacing steps are + // deliberate and are added back, so they never shrink the budget. + expect(timeouts).toEqual([15_000, 9_000, 3_000]) + }) + it('free text: opens "Type something", types the sanitized answer, then Enter', async () => { const sendRequest = vi.fn().mockResolvedValue(acceptedResponse()) await mount({ sendRequest } as unknown as RpcClient, vi.fn()) @@ -298,6 +327,34 @@ describe('useMobileNativeChatAnswerSend', () => { expect(onSendError).toHaveBeenCalledWith('Answer not sent') }) + it('does not call a budget-truncated multi-question answer a definite non-send', async () => { + const onSendError = vi.fn() + const sendRequest = vi.fn(async () => { + // A slow relay: the first group lands, then the shared budget is gone and the + // next write short-circuits to 'rejected' without reaching the wire. + vi.advanceTimersByTime(16_000) + return acceptedResponse() + }) + await mount({ sendRequest } as unknown as RpcClient, onSendError) + + const prompt: AskPrompt = { + questions: [ + { question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] }, + { question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] } + ] + } + let result: Promise | undefined + await act(async () => { + result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }]) + }) + await act(async () => vi.runAllTimersAsync()) + + await expect(result).resolves.toBe(false) + // The first group DID land, so the remote selector is half-stepped — telling the + // user nothing was sent invites a retry on top of the advanced state. + expect(onSendError).toHaveBeenCalledWith('Answer partly sent — check chat before retrying') + }) + it('reports an ambiguous write as unconfirmed instead of a definite failure', async () => { const onSendError = vi.fn() const sendRequest = vi diff --git a/mobile/src/session/use-mobile-native-chat-answer-send.ts b/mobile/src/session/use-mobile-native-chat-answer-send.ts index 021a948a2a2..11a3a030788 100644 --- a/mobile/src/session/use-mobile-native-chat-answer-send.ts +++ b/mobile/src/session/use-mobile-native-chat-answer-send.ts @@ -9,7 +9,10 @@ import { type AskAnswerSelection, type AskPrompt } from './mobile-native-chat-ask' -import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' +import { + openMobileNativeChatSendBudget, + sendMobileNativeChatMessageWithOutcome +} from './mobile-native-chat-send' import { healMobileNativeChatStaleInput } from './mobile-native-chat-stale-input' import { resolveNativeChatTranscriptAgent, @@ -104,6 +107,12 @@ export function useMobileNativeChatAnswerSend(args: { cancelPending() const generation = generationRef.current let sawUnknownOutcome = false + let sawAcceptedGroup = false + // One budget for the whole answer instead of a fresh timeout per keystroke + // group, which let an N-group selector hold the card for N × the send timeout. + // It bounds transport time only: each deliberate pacing wait is credited back + // below, so a long multi-question answer still gets a full budget to write in. + let deadline = openMobileNativeChatSendBudget() const sendTerminal = async (body: string, enter: boolean): Promise => { const activeRoute = activeRouteRef.current if ( @@ -120,6 +129,7 @@ export function useMobileNativeChatAnswerSend(args: { terminal: handle, text: body, enter, + deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) @@ -127,6 +137,9 @@ export function useMobileNativeChatAnswerSend(args: { if (outcome === 'unknown') { sawUnknownOutcome = true } + if (outcome === 'accepted') { + sawAcceptedGroup = true + } return outcome === 'accepted' } const wait = (ms: number): Promise => @@ -144,11 +157,16 @@ export function useMobileNativeChatAnswerSend(args: { if (generationRef.current === generation) { // Why: keystrokes that may have landed (ack lost / path cutover) must // not read as a definite failure — a blind resend could double-step - // the selector. + // the selector. An earlier group that WAS accepted is the same hazard + // in definite form: a multi-question answer whose shared budget ran out + // mid-sequence left the remote selector half-stepped, and telling the + // user nothing was sent invites a retry on top of the advanced state. onSendError( - sawUnknownOutcome - ? 'Answer unconfirmed — check chat before retrying' - : 'Answer not sent' + sawAcceptedGroup + ? 'Answer partly sent — check chat before retrying' + : sawUnknownOutcome + ? 'Answer unconfirmed — check chat before retrying' + : 'Answer not sent' ) } return false @@ -168,7 +186,8 @@ export function useMobileNativeChatAnswerSend(args: { !(await healMobileNativeChatStaleInput({ client, terminal: handle, - deviceToken: deviceTokenRef.current + deviceToken: deviceTokenRef.current, + deadline })) ) { if (generationRef.current === generation) { @@ -194,8 +213,12 @@ export function useMobileNativeChatAnswerSend(args: { if (!(await sendTerminal(body, false))) { return fail() } - if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { - return false + if (index < groups.length - 1) { + if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) { + return false + } + // Pacing is deliberate, not transport latency — don't charge it to the budget. + deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } } return groups.length > 0 diff --git a/mobile/src/session/use-mobile-native-chat-cancel-ask.ts b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts new file mode 100644 index 00000000000..fb09f2d4aad --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-cancel-ask.ts @@ -0,0 +1,45 @@ +import { useCallback, type MutableRefObject } from 'react' +import type { RpcClient } from '../transport/rpc-client' +import { sendMobileNativeChatMessageWithOutcome } from './mobile-native-chat-send' + +/** Sends the Escape that dismisses an ask/question card. Its own module for the + * same reason stop/permission/answer are: the controller owns composition, not + * the per-action write semantics. */ +export function useMobileNativeChatCancelAsk(args: { + client: RpcClient | null + enabled: boolean + handleRef: MutableRefObject + deviceTokenRef: MutableRefObject + /** Drops any in-flight paced answer writes before the Escape lands. */ + cancelPending: () => void + onSendError: (message: string) => void +}): () => Promise { + const { client, enabled, handleRef, deviceTokenRef, cancelPending, onSendError } = args + return useCallback(async (): Promise => { + const handle = handleRef.current + if (!client || !handle || !enabled) { + onSendError('Cancel not sent (disconnected)') + return false + } + cancelPending() + // Escape never submits the composer, so no stale-input heal: it would consume + // the marker still protecting the next real message. + const outcome = await sendMobileNativeChatMessageWithOutcome({ + client, + terminal: handle, + text: String.fromCharCode(27), + enter: false, + ...(deviceTokenRef.current + ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } + : {}) + }) + if (outcome === 'unknown') { + // Why: the Escape may have landed (ack lost / path cutover) — a definite + // "not sent" would invite a second Escape into a changed prompt state. + onSendError('Cancel unconfirmed — check chat before retrying') + } else if (outcome === 'rejected') { + onSendError('Cancel not sent') + } + return outcome === 'accepted' + }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError]) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index 37cbfdc0ae5..7118d23321b 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -2,6 +2,7 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' const acceptSend = vi.fn() const captureSendOrigin = vi.fn() @@ -44,7 +45,10 @@ vi.mock('./use-mobile-native-chat-stop', () => ({ vi.mock('./use-mobile-native-chat-file-search', () => ({ useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() }) })) -vi.mock('./mobile-native-chat-send', () => ({ +// Partial: the stale-input heal reaches the real transport through image-send, +// which must read the REAL timeout constant, not a copy that can silently drift. +vi.mock('./mobile-native-chat-send', async (importOriginal) => ({ + ...(await importOriginal()), sendMobileNativeChatMessageWithOutcome: vi.fn() })) @@ -73,13 +77,15 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { let renderer: ReactTestRenderer | null = null let controller: MobileNativeChatController | null = null const onSendError = vi.fn() + const onSendResolved = vi.fn() // Only the stale-input heal reaches the transport directly (the message send // itself is mocked above). const clientStub = { sendRequest: vi.fn() } - function Harness(): null { + function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null { controller = useMobileNativeChatController({ client: clientStub as unknown as RpcClient, + connState, hostId: 'h', worktreeId: 'w', activeSessionTab: null, @@ -88,7 +94,8 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { deviceTokenRef: { current: null }, nativeChatTranscriptIsLocalReadable: true, nativeChatInputLeaseReady: true, - onSendError + onSendError, + onSendResolved }) return null } @@ -176,6 +183,23 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(isMobileNativeChatInputStale('term-1')).toBe(true) }) + it('retires a held failure banner when a card action is accepted', async () => { + // The banner is route-owned and outlives the write that raised it, so an accepted + // answer or permission reply must clear it too — not just a composer send. + sendWithOutcome.mockResolvedValue('accepted') + await act(async () => { + await controller!.handleNativeChatCancelAsk() + }) + expect(onSendResolved).toHaveBeenCalled() + + onSendResolved.mockClear() + sendWithOutcome.mockResolvedValue('rejected') + await act(async () => { + await controller!.handleNativeChatCancelAsk() + }) + expect(onSendResolved).not.toHaveBeenCalled() + }) + it('threads the optimistic-echo image URIs into acceptSend on an accepted send', async () => { sendWithOutcome.mockResolvedValue('accepted') let accepted = false @@ -215,6 +239,23 @@ describe('useMobileNativeChatController handleNativeChatSend', () => { expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function)) }) + it('fails a send fast while the socket is down, before spending the heal budget', async () => { + // The lease collapses a render after connState, so a question-card answer could + // otherwise sit in `sending` for the whole 15s heal+send budget. + markMobileNativeChatInputStale('term-1') + await act(async () => { + renderer?.update(createElement(Harness, { connState: 'connecting' })) + }) + let accepted = true + await act(async () => { + accepted = await controller!.handleNativeChatSend('answer') + }) + expect(accepted).toBe(false) + expect(clientStub.sendRequest).not.toHaveBeenCalled() + expect(sendWithOutcome).not.toHaveBeenCalled() + expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)') + }) + it('reports a rejected send and posts no echo', async () => { sendWithOutcome.mockResolvedValue('rejected') let accepted = true diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index e65ad2e242a..8ca4b0b6075 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -7,6 +7,7 @@ import { } from 'react' import { useMobileSessionViewMode } from './use-mobile-session-view-mode' import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' import { parseAskFromStatus, type AskAnswerSelection, @@ -17,11 +18,9 @@ import { detectAgentPermission } from './mobile-native-chat-permission' import { parseAgentQuestion } from './mobile-native-chat-question' import { openMobileNativeChatFile } from './mobile-native-chat-open-file' import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send' -import { - sendMobileNativeChatMessageWithOutcome, - type MobileNativeChatSendOutcome -} from './mobile-native-chat-send' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send' +import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask' import { useMobileNativeChatDrafts, type MobileNativeChatPendingMessage @@ -31,6 +30,7 @@ import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message import { useMobileNativeChatSession } from './use-mobile-native-chat-session' import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' +import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes' import { useThrottledLatestValue } from './use-throttled-latest-value' const NATIVE_CHAT_STREAM_THROTTLE_MS = 50 @@ -65,10 +65,13 @@ export type MobileNativeChatController = { handleNativeChatQuestionAnswer: (text: string) => Promise handleNativeChatSend: (text: string, images?: string[]) => Promise /** Outcome-preserving send: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. */ + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ handleNativeChatSendWithOutcome: ( text: string, - images?: string[] + images?: string[], + deadline?: number ) => Promise } @@ -84,7 +87,12 @@ export function useMobileNativeChatController(args: { deviceTokenRef: MutableRefObject nativeChatTranscriptIsLocalReadable: boolean nativeChatInputLeaseReady: boolean + /** Live socket state; the lease collapses on disconnect but one render later. */ + connState: ConnectionState onSendError: (message: string) => void + /** Retires a held failure banner. Any accepted chat write clears it — a delivered + * answer or permission reply must not sit under a stale "not sent". */ + onSendResolved: () => void }): MobileNativeChatController { const { client, @@ -96,7 +104,9 @@ export function useMobileNativeChatController(args: { deviceTokenRef, nativeChatTranscriptIsLocalReadable, nativeChatInputLeaseReady, - onSendError + connState, + onSendError, + onSendResolved } = args const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId }) @@ -169,10 +179,14 @@ export function useMobileNativeChatController(args: { [activeHandleRef, client, worktreeId] ) + // Every chat write gates on both: the lease proves the input floor is ours, and + // `connState` collapses a render before the lease does on disconnect. + const inputSendable = nativeChatInputLeaseReady && connState === 'connected' + const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } = useMobileNativeChatAnswerSend({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, agentRef: activeChatAgentRef, @@ -181,44 +195,18 @@ export function useMobileNativeChatController(args: { onSendError }) - const handleNativeChatCancelAsk = useCallback(async (): Promise => { - const handle = activeHandleRef.current - if (!client || !handle || !nativeChatInputLeaseReady) { - onSendError('Cancel not sent (disconnected)') - return false - } - cancelNativeChatAnswer() - // Escape never submits the composer, so no stale-input heal: it would consume - // the marker still protecting the next real message. - const outcome = await sendMobileNativeChatMessageWithOutcome({ - client, - terminal: handle, - text: String.fromCharCode(27), - enter: false, - ...(deviceTokenRef.current - ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } - : {}) - }) - if (outcome === 'unknown') { - // Why: the Escape may have landed (ack lost / path cutover) — a definite - // "not sent" would invite a second Escape into a changed prompt state. - onSendError('Cancel unconfirmed — check chat before retrying') - } else if (outcome === 'rejected') { - onSendError('Cancel not sent') - } - return outcome === 'accepted' - }, [ - activeHandleRef, - cancelNativeChatAnswer, + const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({ client, + enabled: inputSendable, + handleRef: activeHandleRef, deviceTokenRef, - nativeChatInputLeaseReady, + cancelPending: cancelNativeChatAnswer, onSendError - ]) + }) const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, onSendError @@ -226,7 +214,7 @@ export function useMobileNativeChatController(args: { const handleNativeChatStop = useMobileNativeChatStop({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, streamIdentity, @@ -245,7 +233,7 @@ export function useMobileNativeChatController(args: { answerQuestion: handleNativeChatQuestionAnswer } = useMobileNativeChatMessageSend({ client, - enabled: nativeChatInputLeaseReady, + enabled: inputSendable, handleRef: activeHandleRef, deviceTokenRef, captureSendOrigin, @@ -255,6 +243,10 @@ export function useMobileNativeChatController(args: { holdUnconfirmedSend, onSendError }) + // Card actions retire the route's held failure banner too, not just sends. + const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved) + const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved) + const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved) return { isTabChatView, @@ -272,9 +264,9 @@ export function useMobileNativeChatController(args: { nativeChatQuestion, nativeChatAsk, handleNativeChatOpenFile, - handleNativeChatAnswerAsk, - handleNativeChatCancelAsk, - handleNativeChatRespondPermission, + handleNativeChatAnswerAsk: answerAsk, + handleNativeChatCancelAsk: cancelAsk, + handleNativeChatRespondPermission: respond, handleNativeChatStop, nativeChatFilePaths, loadNativeChatFiles, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 8079a48d2b1..68ae3e2dc05 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -67,6 +67,7 @@ function baseArgs(overrides: Partial & Pick): Hook scopeKey: SCOPE_A, enabled: true, showToast: vi.fn(), + onSendError: vi.fn(), baseSend: vi.fn().mockResolvedValue('accepted'), sleep: async () => {}, ...overrides @@ -195,11 +196,51 @@ describe('useMobileNativeChatImageAttachments', () => { // Clear, then paste, then settle, then the text send — in that order. expect(order).toEqual(['clear', 'paste', 'settle', 'text:look at this']) // The local preview URI rides along so the sent bubble shows the photo. - expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg']) + expect(baseSend).toHaveBeenCalledWith('look at this', ['file:///a.jpg'], expect.any(Number)) // Chips clear once the send is accepted. expect(hook!.attachments).toEqual([]) }) + it('spends one budget across the image paste and the text body that follows', async () => { + vi.useFakeTimers() + try { + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), // Ctrl+U clear + sendResult(true) // image paste + ]) + const slowClient: Pick = { + sendRequest: async (method, params) => { + if (method === 'terminal.send') { + // A slow relay: each write burns 5s of the action's budget. + vi.setSystemTime(Date.now() + 5_000) + } + return client.sendRequest(method, params) + } + } + const baseSend = vi.fn().mockResolvedValue('accepted') + mount(baseArgs({ client: slowClient as RpcClient, baseSend })) + + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('look at this') + }) + + // The paste spent 10s of the 15s ceiling, so the text body inherits what is + // left (plus the credited settle). Opening a fresh budget here — which the + // caller used to do by omitting `deadline` — would hand it a full 15s and let + // one user action hold the composer `sending` for ~30s. + const deadline = baseSend.mock.calls[0]?.[2] as number + expect(deadline - Date.now()).toBeLessThanOrEqual(5_300) + } finally { + vi.useRealTimers() + } + }) + it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) const client = makeClient([ @@ -222,7 +263,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(accepted).toBe(true) // Empty text still goes through baseSend (which submits the bare Enter) so the // optimistic echo carries the preview URI. - expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg']) + expect(baseSend).toHaveBeenCalledWith('', ['file:///a.jpg'], expect.any(Number)) const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') // Only the clear + image paste hit the wire here; baseSend owns the submit. expect(sendCalls).toHaveLength(2) @@ -237,7 +278,7 @@ describe('useMobileNativeChatImageAttachments', () => { await act(async () => { await hook!.sendNativeChat('just text') }) - expect(baseSend).toHaveBeenCalledWith('just text') + expect(baseSend).toHaveBeenCalledWith('just text', undefined, expect.any(Number)) expect(client.calls).toHaveLength(0) }) @@ -250,8 +291,8 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(false) // image paste rejected ]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() - mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast })) + const onSendError = vi.fn() + mount(baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError })) await act(async () => { await hook!.attachImage('library') }) @@ -261,7 +302,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent') expect(hook!.attachments).toHaveLength(1) }) @@ -270,8 +311,8 @@ describe('useMobileNativeChatImageAttachments', () => { // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() - mount(baseArgs({ client: client as unknown as RpcClient, baseSend, showToast })) + const onSendError = vi.fn() + mount(baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError })) await act(async () => { await hook!.attachImage('library') }) @@ -281,17 +322,19 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent') expect(hook!.attachments).toHaveLength(1) }) - it('surfaces a toast instead of a silent no-op when the input lease gate is closed', async () => { + it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() + const onSendError = vi.fn() // Attaching is allowed without the lease; only the send is gated on it. - mount(baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, showToast })) + mount( + baseArgs({ client: client as unknown as RpcClient, enabled: false, baseSend, onSendError }) + ) await act(async () => { await hook!.attachImage('library') }) @@ -301,7 +344,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent (disconnected)', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)') expect(hook!.attachments).toHaveLength(1) }) @@ -322,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { await act(async () => { await hook!.sendNativeChat('hi') }) - expect(baseSend).toHaveBeenCalledWith('hi') + expect(baseSend).toHaveBeenCalledWith('hi', undefined, expect.any(Number)) expect(client.calls.some((c) => c.method === 'terminal.send')).toBe(false) // Back on the original tab the chip is still pending. @@ -425,7 +468,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) // Only the first (sent) image rode along; the mid-send chip survives. - expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg']) + expect(baseSend).toHaveBeenCalledWith('hi', ['file:///a.jpg'], expect.any(Number)) expect(hook!.attachments.map((a) => a.previewUri)).toEqual(['file:///b.jpg']) }) @@ -438,14 +481,14 @@ describe('useMobileNativeChatImageAttachments', () => { sendResult(true) // image paste — into term-1 ]) const baseSend = vi.fn().mockResolvedValue('accepted') - const showToast = vi.fn() + const onSendError = vi.fn() const activeHandleRef = { current: 'term-1' } let releaseSettle: (() => void) | null = null const args = baseArgs({ client: client as unknown as RpcClient, activeHandleRef, baseSend, - showToast, + onSendError, sleep: () => new Promise((resolve) => { releaseSettle = resolve @@ -474,7 +517,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(accepted).toBe(false) expect(baseSend).not.toHaveBeenCalled() - expect(showToast).toHaveBeenCalledWith('Message not sent', 1500) + expect(onSendError).toHaveBeenCalledWith('Message not sent') expect(hook!.attachments).toHaveLength(1) }) @@ -511,7 +554,7 @@ describe('useMobileNativeChatImageAttachments', () => { // Failed attempt's clear + rejected paste, then the healing clear. expect(sendCalls).toHaveLength(3) expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) - expect(baseSend).toHaveBeenCalledWith('hi again') + expect(baseSend).toHaveBeenCalledWith('hi again', undefined, expect.any(Number)) }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { @@ -548,8 +591,8 @@ describe('useMobileNativeChatImageAttachments', () => { const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') expect(sendCalls).toHaveLength(3) expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg']) - expect(baseSend).toHaveBeenNthCalledWith(2, 'later message') + expect(baseSend).toHaveBeenNthCalledWith(1, 'pic', ['file:///a.jpg'], expect.any(Number)) + expect(baseSend).toHaveBeenNthCalledWith(2, 'later message', undefined, expect.any(Number)) }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { @@ -585,7 +628,7 @@ describe('useMobileNativeChatImageAttachments', () => { const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') expect(sendCalls).toHaveLength(3) expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(2, 'later message') + expect(baseSend).toHaveBeenNthCalledWith(2, 'later message', undefined, expect.any(Number)) }) it('does not heal after an unknown text-only send (nothing was pasted first)', async () => { @@ -644,8 +687,8 @@ describe('useMobileNativeChatImageAttachments', () => { expect(sendCalls).toHaveLength(4) expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) expect(sendCalls[3]?.params).toMatchObject({ text: '\x15', enter: false }) - expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg']) - expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again') + expect(baseSend).toHaveBeenNthCalledWith(1, 'hi', ['file:///a.jpg'], expect.any(Number)) + expect(baseSend).toHaveBeenNthCalledWith(2, 'hi again', undefined, expect.any(Number)) }) it('does not reroute text when the active terminal changes during a healing clear', async () => { @@ -692,6 +735,49 @@ describe('useMobileNativeChatImageAttachments', () => { expect(sendCalls[2]?.params).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false }) }) + it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { + pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + const client = makeClient([ + methodNotFound('start'), + ok('save', '/tmp/a.png'), + sendResult(true), // Ctrl+U clear + sendResult(true), // image paste accepted + sendResult(true) // the heal, once the lease is back + ]) + const baseSend = vi.fn().mockResolvedValueOnce('rejected').mockResolvedValueOnce('accepted') + const onSendError = vi.fn() + const args = baseArgs({ client: client as unknown as RpcClient, baseSend, onSendError }) + mount(args) + await act(async () => { + await hook!.attachImage('library') + }) + await act(async () => { + await hook!.sendNativeChat('hi') + }) + await act(async () => { + hook!.removeAttachment('img-1') + }) + + // Lease lost: the heal is a terminal.send too, so it must not be attempted. + update({ ...args, enabled: false }) + let accepted = true + await act(async () => { + accepted = await hook!.sendNativeChat('hi again') + }) + expect(accepted).toBe(false) + expect(onSendError).toHaveBeenLastCalledWith('Message not sent (disconnected)') + expect(client.calls.filter((c) => c.method === 'terminal.send')).toHaveLength(2) + + update({ ...args, enabled: true }) + await act(async () => { + accepted = await hook!.sendNativeChat('hi again') + }) + expect(accepted).toBe(true) + const sendCalls = client.calls.filter((c) => c.method === 'terminal.send') + expect(sendCalls).toHaveLength(3) + expect(sendCalls[2]?.params).toMatchObject({ text: '\x15', enter: false }) + }) + it('heals rejected image submits independently across terminals', async () => { pick .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index f0ce66dd92b..7ce1ad8978b 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -15,7 +15,10 @@ import { MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS, pasteMobileNativeChatImagePaths } from './mobile-native-chat-image-send' -import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' +import { + openMobileNativeChatSendBudget, + type MobileNativeChatSendOutcome +} from './mobile-native-chat-send' import { clearMobileNativeChatInputStale, healMobileNativeChatStaleInput, @@ -39,13 +42,19 @@ type Args = { /** The native-chat input lease is ready — same gate `handleNativeChatSend` uses. */ readonly enabled: boolean readonly showToast: ShowToast + /** Send failures go to the composer's inline banner, not the toast — the same + * channel the controller's own rejections use, so one failure paints once. */ + readonly onSendError: (message: string) => void /** The plain text send (controller.handleNativeChatSendWithOutcome); wrapped so * images ride along. The optional URIs drive the optimistic echo's thumbnails. * Must preserve 'unknown': after a successful paste, an ambiguously-delivered - * text+Enter may have left the image on the input line, which needs healing. */ + * text+Enter may have left the image on the input line, which needs healing. + * Accepts this action's budget so the text body draws from what the paste left + * rather than opening a second one. */ readonly baseSend: ( text: string, - imagePreviewUris?: string[] + imagePreviewUris?: string[], + deadline?: number ) => Promise readonly onAttachSuccess?: () => void readonly onError?: () => void @@ -95,6 +104,7 @@ export function useMobileNativeChatImageAttachments({ scopeKey, enabled, showToast, + onSendError, baseSend, onAttachSuccess, onError, @@ -206,12 +216,16 @@ export function useMobileNativeChatImageAttachments({ const operationTerminal = activeHandleRef.current if (operationTerminal && sendInFlightTerminalsRef.current.has(operationTerminal)) { onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } if (operationTerminal) { sendInFlightTerminalsRef.current.add(operationTerminal) } + // One budget for the whole user action. The paste loop, the settle, and the + // text body that follows are a single send from the composer's point of view; + // opening a budget per leg let `sending` run to twice the stated ceiling. + const deadline = openMobileNativeChatSendBudget() try { const scope = scopeKey const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS @@ -220,28 +234,38 @@ export function useMobileNativeChatImageAttachments({ // otherwise glue the stale image paste onto this message. Best-effort — // on failure the marker stays set and the text must not be submitted. const staleTerminal = activeHandleRef.current - if (staleTerminal && isMobileNativeChatInputStale(staleTerminal) && client) { + if (staleTerminal && isMobileNativeChatInputStale(staleTerminal)) { + // Why: the heal is itself a terminal.send, so without the input lease it + // can only be rejected — which used to latch the marker and fail every + // later send with a bare "Message not sent" (#10681). Gate it like the + // image path; the heal retries once the lease is back. + if (!client || !enabled || connState !== 'connected') { + onError?.() + onSendError('Message not sent (disconnected)') + return false + } const healed = await healMobileNativeChatStaleInput({ client, terminal: staleTerminal, - deviceToken: deviceTokenRef.current + deviceToken: deviceTokenRef.current, + deadline }) // A tab switch during the clear would send this text to a terminal the // clear never touched, so abort rather than reroute it. if (!healed || activeHandleRef.current !== staleTerminal) { onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } } // Text-only sends paste nothing first, so 'unknown' leaves no stale input. - return (await baseSend(text)) !== 'rejected' + return (await baseSend(text, undefined, deadline)) !== 'rejected' } const handle = activeHandleRef.current if (!client || !handle || !enabled || connState !== 'connected') { onError?.() // Mirror the text path's failure surface (the base send is never reached). - showToast('Message not sent (disconnected)', 1500) + onSendError('Message not sent (disconnected)') return false } try { @@ -249,13 +273,14 @@ export function useMobileNativeChatImageAttachments({ client, terminal: handle, deviceToken: deviceTokenRef.current, - imagePaths: pendingImages.map((attachment) => attachment.path) + imagePaths: pendingImages.map((attachment) => attachment.path), + deadline }) if (!pasted) { // Keep the chips so the user can retry; the failed paste never submitted. markMobileNativeChatInputStale(handle) onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } // The paste's leading Ctrl+U cleared any earlier stale input in `handle`. @@ -264,18 +289,22 @@ export function useMobileNativeChatImageAttachments({ // preview URIs ride along to baseSend so the sent bubble shows the photo // immediately (empty text still submits a bare Enter through baseSend). await sleep(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS) + // The settle is deliberate pacing, not transport latency — credit it back + // so a shared budget doesn't charge the text body for the TUI's beat. + const textDeadline = deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS // The paste above targeted `handle`; a tab switch during the settle would // route the text + Enter to a different terminal than the images. Abort — // the chips keep their scope and a retry's Ctrl+U clears the stale paste. if (activeHandleRef.current !== handle) { markMobileNativeChatInputStale(handle) onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } const outcome = await baseSend( text, - pendingImages.map((attachment) => attachment.previewUri) + pendingImages.map((attachment) => attachment.previewUri), + textDeadline ) if (outcome !== 'accepted') { // 'rejected' leaves the pasted image path on this input line; 'unknown' @@ -303,7 +332,7 @@ export function useMobileNativeChatImageAttachments({ // attempt's leading Ctrl+U clears whatever fraction of the paste landed. markMobileNativeChatInputStale(handle) onError?.() - showToast('Message not sent', 1500) + onSendError('Message not sent') return false } } finally { @@ -321,8 +350,8 @@ export function useMobileNativeChatImageAttachments({ deviceTokenRef, enabled, onError, + onSendError, scopeKey, - showToast, sleep ] ) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts index bb90327c5b9..c7e60170f0a 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.test.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.test.ts @@ -54,4 +54,36 @@ describe('useMobileNativeChatInputLease', () => { consoleSpy.mockRestore() } }) + + it('reports whether a clear actually dropped a lease', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { connected: true })) + }) + // The route reads this to tell a real teardown from one React never sees. + expect(lease?.clear('terminal')).toBe(false) + expect(lease?.clear()).toBe(false) + + act(() => lease?.markReady('terminal')) + let dropped: boolean | undefined + act(() => { + dropped = lease?.clear('terminal') + }) + expect(dropped).toBe(true) + expect(lease?.ready).toBe(false) + expect(lease?.clear('terminal')).toBe(false) + + act(() => lease?.markReady('other')) + expect(lease?.clear()).toBe(true) + } finally { + consoleSpy.mockRestore() + } + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-input-lease.ts b/mobile/src/session/use-mobile-native-chat-input-lease.ts index 02dfeff84a8..fddf9274573 100644 --- a/mobile/src/session/use-mobile-native-chat-input-lease.ts +++ b/mobile/src/session/use-mobile-native-chat-input-lease.ts @@ -9,9 +9,13 @@ export function useMobileNativeChatInputLease(args: { readyRef: { readonly current: boolean } lockReason: MobileNativeChatInputLockReason | null markReady: (handle: string) => void - clear: (handle?: string) => void + /** Returns whether the clear actually dropped a lease — callers use a no-op clear + * to detect a teardown React would otherwise never see (#10681). */ + clear: (handle?: string) => boolean } { const [readyHandles, setReadyHandles] = useState>(new Set()) + // Mirrors the state so `clear` can report synchronously whether it changed anything. + const readyHandlesRef = useRef(readyHandles) const ready = args.activeHandle != null && readyHandles.has(args.activeHandle) // Why: absence of an acknowledgement proves only that setup is still pending; // the protocol does not report evidence that another client owns the floor. @@ -22,27 +26,44 @@ export function useMobileNativeChatInputLease(args: { : 'waiting' const readyRef = useRef(ready) readyRef.current = ready - useEffect(() => { - if (!args.connected) { - setReadyHandles(new Set()) - } - }, [args.connected]) - const markReady = useCallback((handle: string) => { - setReadyHandles((current) => new Set(current).add(handle)) + const replace = useCallback((next: Set) => { + readyHandlesRef.current = next + setReadyHandles(next) }, []) - const clear = useCallback((handle?: string) => { - setReadyHandles((current) => { + useEffect(() => { + if (!args.connected && readyHandlesRef.current.size > 0) { + replace(new Set()) + } + }, [args.connected, replace]) + const markReady = useCallback( + (handle: string) => { + if (readyHandlesRef.current.has(handle)) { + return + } + replace(new Set(readyHandlesRef.current).add(handle)) + }, + [replace] + ) + const clear = useCallback( + (handle?: string): boolean => { + const current = readyHandlesRef.current if (handle === undefined) { - return new Set() + if (current.size === 0) { + return false + } + replace(new Set()) + return true } if (!current.has(handle)) { - return current + return false } const next = new Set(current) next.delete(handle) - return next - }) - }, []) + replace(next) + return true + }, + [replace] + ) return { ready, readyRef, diff --git a/mobile/src/session/use-mobile-native-chat-message-send.ts b/mobile/src/session/use-mobile-native-chat-message-send.ts index 939aee06fd8..559524f3c5d 100644 --- a/mobile/src/session/use-mobile-native-chat-message-send.ts +++ b/mobile/src/session/use-mobile-native-chat-message-send.ts @@ -1,6 +1,7 @@ import { useCallback, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' import { + openMobileNativeChatSendBudget, sendMobileNativeChatMessageWithOutcome, type MobileNativeChatSendOutcome } from './mobile-native-chat-send' @@ -11,8 +12,14 @@ export type MobileNativeChatMessageSend = { /** Composer send that syncs the draft (clear on send, restore on rejection). */ send: (text: string, images?: string[]) => Promise /** Outcome-preserving variant: callers that pasted terminal input beforehand - * (image sends) must see 'unknown' to heal a possibly-orphaned paste. */ - sendWithOutcome: (text: string, images?: string[]) => Promise + * (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a + * caller passes its own `deadline` so the paste it already spent and this text + * body share one budget instead of holding the composer for two. */ + sendWithOutcome: ( + text: string, + images?: string[], + deadline?: number + ) => Promise /** Answer to an agent question — never touches the composer draft. */ answerQuestion: (text: string) => Promise } @@ -52,10 +59,14 @@ export function useMobileNativeChatMessageSend(args: { async ( text: string, images: string[] | undefined, - syncComposer: boolean + syncComposer: boolean, + sharedDeadline?: number ): Promise => { const handle = handleRef.current const origin = captureSendOrigin(text) + // Why: the lease collapses one render after `connState`, so a question-card + // answer (which reaches this send directly) would otherwise burn the whole + // 15s heal+send budget waiting on a socket that is already gone. if (!client || !handle || !origin || !enabled) { onSendError('Message not sent (disconnected)') return 'rejected' @@ -64,7 +75,16 @@ export function useMobileNativeChatMessageSend(args: { // send (#10228); submitting on top of it would glue the image onto this // message. Healed before the draft clear so a failed heal — which sends // nothing — leaves the composer exactly as the user left it. - const healArgs = { client, terminal: handle, deviceToken: deviceTokenRef.current } + // One budget for the whole action: a hung heal must eat into the text send's + // time, not hand it a fresh timeout and pin the composer for twice as long. + // An image send already opened one covering its paste — keep spending that. + const deadline = sharedDeadline ?? openMobileNativeChatSendBudget() + const healArgs = { + client, + terminal: handle, + deviceToken: deviceTokenRef.current, + deadline + } if (!(await healMobileNativeChatStaleInput(healArgs))) { onSendError('Message not sent') return 'rejected' @@ -79,6 +99,7 @@ export function useMobileNativeChatMessageSend(args: { client, terminal: handle, text, + deadline, ...(deviceTokenRef.current ? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } } : {}) @@ -118,7 +139,8 @@ export function useMobileNativeChatMessageSend(args: { ) const sendWithOutcome = useCallback( - (text: string, images?: string[]) => sendMessage(text, images, true), + (text: string, images?: string[], deadline?: number) => + sendMessage(text, images, true, deadline), [sendMessage] ) diff --git a/mobile/src/session/use-mobile-native-chat-send-error.test.ts b/mobile/src/session/use-mobile-native-chat-send-error.test.ts new file mode 100644 index 00000000000..a22eee3a0a4 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-send-error.test.ts @@ -0,0 +1,223 @@ +import { createElement } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useMobileNativeChatSendError } from './use-mobile-native-chat-send-error' + +type HookApi = ReturnType + +describe('useMobileNativeChatSendError', () => { + let renderer: ReactTestRenderer | null = null + const apiRef = { current: null as HookApi | null } + + const showToast = vi.fn() + + function Harness({ + scopeKey, + bannerMounted = true + }: { + scopeKey: string | null + bannerMounted?: boolean + }): null { + const api = useMobileNativeChatSendError({ scopeKey, showToast }) + api.bannerMountedRef.current = bannerMounted + apiRef.current = api + return null + } + + function api(): HookApi { + if (!apiRef.current) { + throw new Error('Harness was not rendered') + } + return apiRef.current + } + + /** react-test-renderer logs a deprecation notice on every render; keep real errors. */ + function suppressRendererWarning(): () => void { + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + return () => spy.mockRestore() + } + + async function render(scopeKey: string | null = 'terminal-1'): Promise { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness, { scopeKey })) + }) + } finally { + restore() + } + } + + beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + apiRef.current = null + showToast.mockClear() + vi.useFakeTimers() + }) + + afterEach(() => { + act(() => renderer?.unmount()) + renderer = null + vi.useRealTimers() + }) + + it('holds a failure for four seconds, then drops it', async () => { + await render() + await act(async () => api().show('a')) + expect(api().message).toBe('a') + + await act(async () => { + vi.advanceTimersByTime(4000) + }) + expect(api().message).toBeNull() + }) + + it('restarts the hold when a second failure lands mid-hold', async () => { + await render() + await act(async () => api().show('a')) + await act(async () => { + vi.advanceTimersByTime(3000) + }) + await act(async () => api().show('b')) + + // The first failure's timer must not survive to clear the second message. + await act(async () => { + vi.advanceTimersByTime(3000) + }) + expect(api().message).toBe('b') + + await act(async () => { + vi.advanceTimersByTime(1000) + }) + expect(api().message).toBeNull() + }) + + it('clears immediately and cancels the pending hold', async () => { + await render() + await act(async () => api().show('a')) + await act(async () => api().clear()) + expect(api().message).toBeNull() + + await act(async () => api().show('b')) + await act(async () => api().clear()) + await act(async () => { + vi.advanceTimersByTime(10_000) + }) + expect(api().message).toBeNull() + }) + + it('drops a held failure when the scope changes', async () => { + await render('terminal-1') + await act(async () => api().show('a')) + expect(api().message).toBe('a') + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) + } finally { + restore() + } + expect(api().message).toBeNull() + }) + + it('falls back to the toast when the banner is not mounted', async () => { + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer = create(createElement(Harness, { scopeKey: 'terminal-1', bannerMounted: false })) + }) + } finally { + restore() + } + // A deferred failure landing after the user left chat must still be seen. + await act(async () => api().show('Delivery unconfirmed')) + + expect(showToast).toHaveBeenCalledWith('Delivery unconfirmed', 1600) + expect(api().message).toBeNull() + }) + + it('toasts a deferred failure that resolves after the user switched tabs', async () => { + await render('terminal-1') + // Captured while tab A was live; a 20s unconfirmed send resolves much later. + const showFromTabA = api().show + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) + } finally { + restore() + } + await act(async () => showFromTabA('Message not sent')) + + // The banner belongs to terminal-2 now, so A's failure must not paint there. + expect(api().message).toBeNull() + expect(showToast).toHaveBeenCalledWith('Message not sent', 1600) + }) + + it('does not let a stale scope clear the banner the live scope is showing', async () => { + await render('terminal-1') + const clearFromTabA = api().clear + + const restore = suppressRendererWarning() + try { + await act(async () => { + renderer?.update(createElement(Harness, { scopeKey: 'terminal-2' })) + }) + } finally { + restore() + } + await act(async () => api().show('b')) + // An accepted card action from tab A resolving late must not retire B's warning. + await act(async () => clearFromTabA()) + + expect(api().message).toBe('b') + }) + + it('toasts a failure that resolves after the route unmounted', async () => { + await render() + const showWhileMounted = api().show + + act(() => renderer?.unmount()) + renderer = null + // The route writes bannerMountedRef during render, so an unmount leaves it + // stuck true — the failure would target a banner that no longer exists. + await act(async () => showWhileMounted('Delivery unconfirmed')) + + expect(showToast).toHaveBeenCalledWith('Delivery unconfirmed', 1600) + }) + + it('does not fire the hold timer after unmount', async () => { + await render() + await act(async () => api().show('a')) + + const errors: unknown[] = [] + const original = console.error + const spy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + errors.push(args[0]) + original(...args) + }) + try { + act(() => renderer?.unmount()) + renderer = null + act(() => { + vi.advanceTimersByTime(4000) + }) + } finally { + spy.mockRestore() + } + expect(errors).toEqual([]) + }) +}) diff --git a/mobile/src/session/use-mobile-native-chat-send-error.ts b/mobile/src/session/use-mobile-native-chat-send-error.ts new file mode 100644 index 00000000000..359b94a8336 --- /dev/null +++ b/mobile/src/session/use-mobile-native-chat-send-error.ts @@ -0,0 +1,80 @@ +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' + +const NATIVE_CHAT_SEND_ERROR_HOLD_MS = 4000 +const NATIVE_CHAT_SEND_ERROR_TOAST_MS = 1600 + +/** Holds the newest native-chat send failure for the composer's inline banner. + * Why a banner and not the bottom toast: chat failures happen with the keyboard + * up, which covers the toast — the surface the user is looking at is the composer. + * Scoped like drafts and image chips: a failure belongs to the terminal it was + * raised on and must not follow the user to another tab. */ +export function useMobileNativeChatSendError(args: { + scopeKey: string | null + showToast: (message: string, durationMs?: number) => void +}): { + message: string | null + show: (message: string) => void + clear: () => void + /** Set by the route each render; gates banner vs toast. */ + bannerMountedRef: MutableRefObject +} { + const [message, setMessage] = useState(null) + const timerRef = useRef | null>(null) + const bannerMountedRef = useRef(false) + const showToastRef = useRef(args.showToast) + showToastRef.current = args.showToast + // Why: `show`/`clear` are handed to sends that resolve much later (a 20s + // unconfirmed send, a paced answer). Comparing the scope they were built for + // against the live one is what stops tab A's late outcome from painting — or + // wiping — tab B's banner. + const liveScopeRef = useRef(args.scopeKey) + liveScopeRef.current = args.scopeKey + const scopeKey = args.scopeKey + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current) + timerRef.current = null + } + }, []) + const clear = useCallback(() => { + if (liveScopeRef.current !== scopeKey) { + return + } + clearTimer() + setMessage(null) + }, [clearTimer, scopeKey]) + const show = useCallback( + (next: string) => { + // Why: deferred failures can land after the user left chat (banner unmounted) + // or moved to another tab, where the banner belongs to a different terminal — + // both must fall back to the toast instead of being swallowed or misattributed. + if (liveScopeRef.current !== scopeKey || !bannerMountedRef.current) { + showToastRef.current(next, NATIVE_CHAT_SEND_ERROR_TOAST_MS) + return + } + clearTimer() + setMessage(next) + timerRef.current = setTimeout(() => { + timerRef.current = null + setMessage(null) + }, NATIVE_CHAT_SEND_ERROR_HOLD_MS) + }, + [clearTimer, scopeKey] + ) + // A held failure describes the scope it was raised on; drop it when that changes. + useEffect(() => { + clearTimer() + setMessage(null) + }, [clearTimer, scopeKey]) + useEffect( + () => () => { + // Why: the route writes this ref during render, so an unmount leaves it stuck + // true and a pending send's late failure would target a banner that no longer + // exists — swallowing the one signal the toast fallback is here to carry. + bannerMountedRef.current = false + clearTimer() + }, + [clearTimer] + ) + return { message, show, clear, bannerMountedRef } +} diff --git a/mobile/src/session/use-mobile-native-chat-stop.test.ts b/mobile/src/session/use-mobile-native-chat-stop.test.ts index 27385f8d33e..2a5460b67fa 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.test.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.test.ts @@ -2,6 +2,8 @@ import { createElement } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' +import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS } from './mobile-native-chat-send' import { useMobileNativeChatStop } from './use-mobile-native-chat-stop' describe('useMobileNativeChatStop', () => { @@ -13,7 +15,10 @@ describe('useMobileNativeChatStop', () => { beforeEach(() => { vi.useFakeTimers() globalThis.IS_REACT_ACT_ENVIRONMENT = true - sendRequest.mockReset().mockResolvedValue({ ok: true }) + sendRequest.mockReset().mockResolvedValue({ + ok: true, + result: { send: { accepted: true } } + }) onSendError.mockReset() }) @@ -82,4 +87,102 @@ describe('useMobileNativeChatStop', () => { expect(onSendError).toHaveBeenCalledOnce() expect(onSendError).toHaveBeenCalledWith('Stop not sent') }) + + it.each([ + ['RPC failure', { ok: false, error: { code: 'stale', message: 'stale' } }], + ['non-accepted send', { ok: true, result: { send: { accepted: false } } }] + ])('reports Stop not sent after a resolved %s', async (_case, response) => { + sendRequest.mockResolvedValue(response) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => vi.runAllTimersAsync()) + + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop not sent') + }) + + it.each([ + [ + 'an ack lost after the frame was written', + () => markRpcDeliveryUnknown(new Error('rpc timeout')) + ], + ['a logical client cutover', () => new Error('RPC interrupted by connection migration')] + ])('reports Stop as unconfirmed after %s', async (_case, makeError) => { + sendRequest.mockRejectedValue(makeError()) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + // The Escape may have landed; a definite "not sent" invites a second Escape. + expect(onSendError).toHaveBeenCalledOnce() + expect(onSendError).toHaveBeenCalledWith('Stop unconfirmed — check chat before retrying') + }) + + it.each([ + ['second', 0], + ['first', 1] + ])('stays quiet when the %s Escape fails after its sibling landed', async (_case, failIndex) => { + let call = 0 + sendRequest.mockImplementation(() => { + const index = call + call += 1 + return index === failIndex + ? Promise.reject(markRpcDeliveryUnknown(new Error('rpc timeout'))) + : Promise.resolve({ ok: true, result: { send: { accepted: true } } }) + }) + await render(true, 'stream-1') + + act(() => stop?.()) + await act(async () => { + await Promise.resolve() + await vi.runAllTimersAsync() + }) + + // Two paced Escapes are one user action: either landing means the agent stopped, + // so a straggler's failure must not tell the user to press Stop again. + expect(sendRequest).toHaveBeenCalledTimes(2) + expect(onSendError).not.toHaveBeenCalled() + }) + + it('bounds the Escape on a reconnect wait instead of parking forever', async () => { + await render(true, 'stream-1') + + act(() => stop?.()) + + // The budget covers the reconnect wait too, so a stop can't outlast its ceiling. + expect(sendRequest).toHaveBeenCalledWith( + 'terminal.send', + expect.anything(), + expect.objectContaining({ + timeoutMs: MOBILE_NATIVE_CHAT_SEND_TIMEOUT_MS, + budgetSpansConnect: true + }) + ) + }) + + it('suppresses an older Stop verdict after a newer Stop succeeds', async () => { + let rejectFirst!: (error: Error) => void + const first = new Promise((_, reject) => { + rejectFirst = reject + }) + sendRequest + .mockReturnValueOnce(first) + .mockResolvedValue({ ok: true, result: { send: { accepted: true } } }) + await render(true, 'stream-1') + + act(() => stop?.()) + act(() => stop?.()) + await act(async () => vi.runAllTimersAsync()) + await act(async () => { + rejectFirst(new Error('late failure')) + await Promise.resolve() + }) + + expect(onSendError).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/use-mobile-native-chat-stop.ts b/mobile/src/session/use-mobile-native-chat-stop.ts index c6d36ea3436..871d221abb2 100644 --- a/mobile/src/session/use-mobile-native-chat-stop.ts +++ b/mobile/src/session/use-mobile-native-chat-stop.ts @@ -1,5 +1,9 @@ import { useCallback, useEffect, useRef, type MutableRefObject } from 'react' import type { RpcClient } from '../transport/rpc-client' +import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity' +import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client' +import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response' +import { openMobileNativeChatSendBudget } from './mobile-native-chat-send' export function useMobileNativeChatStop(args: { client: RpcClient | null @@ -13,20 +17,29 @@ export function useMobileNativeChatStop(args: { const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } = args const timerRef = useRef | null>(null) + const generationRef = useRef(0) + /** Settles the paced second Escape when it is cancelled rather than sent, so a + * first-Escape failure still reports instead of waiting on a write that will + * never happen. */ + const dropSecondEscapeRef = useRef<(() => void) | null>(null) const activeRouteRef = useRef({ client, enabled, streamIdentity }) activeRouteRef.current = { client, enabled, streamIdentity } - useEffect(() => { - if (!enabled && timerRef.current) { + const cancelSecondEscape = useCallback(() => { + if (timerRef.current) { clearTimeout(timerRef.current) timerRef.current = null } - return () => { - if (timerRef.current) { - clearTimeout(timerRef.current) - timerRef.current = null - } - } - }, [client, enabled, streamIdentity]) + const drop = dropSecondEscapeRef.current + dropSecondEscapeRef.current = null + drop?.() + }, []) + useEffect( + () => () => { + generationRef.current += 1 + cancelSecondEscape() + }, + [cancelSecondEscape, client, enabled, streamIdentity] + ) return useCallback(() => { const handle = handleRef.current if (!client || !handle || !enabled) { @@ -34,11 +47,34 @@ export function useMobileNativeChatStop(args: { return } cancelPending() - if (timerRef.current) { - clearTimeout(timerRef.current) - } + generationRef.current += 1 + const generation = generationRef.current + cancelSecondEscape() const stopStreamIdentity = streamIdentity - let failureReported = false + const deadline = openMobileNativeChatSendBudget() + // Why: the two paced Escapes are one user action. Reporting the first one's + // failure the moment it lands told the user a stop failed that the second + // Escape then completed — and a second Stop press writes into changed prompt + // state. Hold the verdict until both have settled, then stay quiet if either + // was accepted. `pending` starts at 1 for the Escape still on its timer. + let pending = 1 + let sawAccepted = false + let sawUnknown = false + let sawRejected = false + const reportIfSettled = (): void => { + if ( + generationRef.current !== generation || + pending > 0 || + sawAccepted || + (!sawUnknown && !sawRejected) + ) { + return + } + // Why: an ack lost after the frame was written (or a logical cutover) may + // still have stopped the agent — a definite "not sent" would invite a second + // Escape into changed state. Mirrors the cancel/answer wording. + onSendError(sawUnknown ? 'Stop unconfirmed — check chat before retrying' : 'Stop not sent') + } const sendEscape = (): void => { const activeRoute = activeRouteRef.current if ( @@ -49,28 +85,71 @@ export function useMobileNativeChatStop(args: { ) { return } + pending += 1 + const timeoutMs = deadline - Date.now() + if (timeoutMs <= 0) { + sawRejected = true + pending -= 1 + reportIfSettled() + return + } void client - .sendRequest('terminal.send', { - terminal: handle, - text: String.fromCharCode(27), - ...(deviceTokenRef.current - ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } - : {}) - }) - .catch(() => { - // Why: disconnect can race either fire-and-forget Escape; surface one - // failure instead of leaking an unhandled RPC rejection. - if (!failureReported) { - failureReported = true - onSendError('Stop not sent') + .sendRequest( + 'terminal.send', + { + terminal: handle, + text: String.fromCharCode(27), + ...(deviceTokenRef.current + ? { client: { id: deviceTokenRef.current, type: 'mobile' as const } } + : {}) + }, + // Why: without this the call parks indefinitely on reconnect, so "Stop not + // sent" never appears and a stale Escape can land minutes later — into a + // composer that by then holds fresh text. + { timeoutMs, budgetSpansConnect: true } + ) + .then((response) => { + if (isTerminalSendRpcAccepted(response)) { + sawAccepted = true + } else { + sawRejected = true } }) + // Why: disconnect can race either fire-and-forget Escape; record one verdict + // instead of leaking an unhandled RPC rejection. + .catch((error: unknown) => { + if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) { + sawUnknown = true + } else { + sawRejected = true + } + }) + .finally(() => { + pending -= 1 + reportIfSettled() + }) } sendEscape() + dropSecondEscapeRef.current = () => { + pending -= 1 + reportIfSettled() + } // Why: two paced Escape bytes reliably stop TUIs without remote coalescing. timerRef.current = setTimeout(() => { timerRef.current = null + dropSecondEscapeRef.current = null sendEscape() + pending -= 1 + reportIfSettled() }, 80) - }, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity]) + }, [ + cancelPending, + cancelSecondEscape, + client, + deviceTokenRef, + enabled, + handleRef, + onSendError, + streamIdentity + ]) } diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts index 92320b9ff86..fdfb5041402 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts @@ -13,6 +13,8 @@ describe('useMobileNativeChatTerminalStream', () => { const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {})) const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle)) const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} } + const notifyListedHandlesRef = { current: (_liveHandles: ReadonlySet): void => {} } + const hasTabsRecoveryNeedRef = { current: (): boolean => false } beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true @@ -30,12 +32,24 @@ describe('useMobileNativeChatTerminalStream', () => { renderer = null }) - function Harness({ showNativeChat }: { showNativeChat: boolean }): null { + function Harness({ + showNativeChat, + activeHandle = 'terminal-1', + leaseReady = true, + streamRevision = 0 + }: { + showNativeChat: boolean + activeHandle?: string + leaseReady?: boolean + streamRevision?: number + }): null { harnessRenderCount += 1 - notifyWebReadyRef.current = useMobileNativeChatTerminalStream({ + const stream = useMobileNativeChatTerminalStream({ showNativeChat, - activeHandle: 'terminal-1', + activeHandle, activeTabType: 'terminal', + leaseReady, + streamRevision, subscriptionsRef, subscribingRef, webReadyRef, @@ -43,9 +57,33 @@ describe('useMobileNativeChatTerminalStream', () => { subscribe, unsubscribe }) + notifyWebReadyRef.current = stream.notifyWebReady + notifyListedHandlesRef.current = stream.notifyListedHandles + hasTabsRecoveryNeedRef.current = stream.hasTabsRecoveryNeed return null } + /** One dead-PTY round trip: `end` tears the stream down (subscription gone, lease + * cleared), then the host's `subscribed` answer to the rearm briefly brings both + * back. Rendering only the `end` half is what let the shipped bound look sound. */ + async function playDeadPtyRoundTrip(revision: number): Promise { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: revision }) + ) + }) + } + it('replaces output with a lease-only stream while covered, then restores output', async () => { const original = console.error const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { @@ -81,6 +119,297 @@ describe('useMobileNativeChatTerminalStream', () => { } }) + it('re-subscribes a covered stream torn down under chat (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + unsubscribe.mockClear() + // What a terminal.list prune / `end` frame does to the lease-only stream: + // the subscription is gone and the lease drops with it. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update(createElement(Harness, { showNativeChat: true, leaseReady: false })) + }) + + expect(subscribe).toHaveBeenCalledOnce() + expect(subscribe).toHaveBeenCalledWith('terminal-1') + // Pins the rearm branch specifically: without it the action falls through to + // the resume tail, which also subscribes — but drops coverage on the way. + expect(unsubscribe).not.toHaveBeenCalled() + } finally { + consoleSpy.mockRestore() + } + }) + + it('stops rearming a handle whose stream never comes back (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // A dead PTY answers every subscribe with `subscribed`+`end`, so the stream is + // gone again on each pass. Unbounded, that is a ~10s resubscribe loop. + for (let revision = 1; revision <= 6; revision += 1) { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + } + + expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) + } finally { + consoleSpy.mockRestore() + } + }) + + it('does not charge the rearm budget for a subscribe its own gates turned away', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // No client / no webview yet: the call returns without registering anything, so + // it never reached the host and must not spend one of the three real tries. + subscribe.mockImplementation(() => {}) + for (let revision = 1; revision <= 5; revision += 1) { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + } + + expect(subscribe).toHaveBeenCalledTimes(5) + } finally { + subscribe.mockImplementation((handle: string) => + subscriptionsRef.current.set(handle, () => {}) + ) + consoleSpy.mockRestore() + } + }) + + it('refills the rearm budget when terminal.list reports the handle again (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + for (let revision = 1; revision <= 5; revision += 1) { + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + leaseReady: false, + streamRevision: revision + }) + ) + }) + } + subscribe.mockClear() + // Budget is spent, so a further teardown signal alone changes nothing. + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: false, streamRevision: 6 }) + ) + }) + expect(subscribe).not.toHaveBeenCalled() + + // A dead-but-listed handle must not buy a new budget on every list refresh, + // or the resubscribe loop this bound exists to stop comes straight back. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + expect(subscribe).not.toHaveBeenCalled() + + // The handle actually went away and came back: it may have a live PTY once more. + await act(async () => { + notifyListedHandlesRef.current(new Set()) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('rearms on a teardown the lease cannot report (#10681)', async () => { + const original = console.error + const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) { + return + } + original(...args) + }) + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // `end` with no preceding `subscribed` leaves the lease untouched, so + // `leaseReady` holds its value and only the revision bump can re-run us. + subscriptionsRef.current.delete('terminal-1') + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 1 }) + ) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + } finally { + consoleSpy.mockRestore() + } + }) + + it('does not let a dead PTY buy a new budget with its own `subscribed` ack', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + // The `subscribed` half re-runs the effect with the stream momentarily up, which + // used to clear the attempt count — the loop refilled its own budget forever. + for (let revision = 1; revision <= 6; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + + expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3) + }) + + it('refills the budget for a rearmed stream that outlives the teardown window', async () => { + vi.useFakeTimers() + try { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + subscribe.mockClear() + for (let revision = 1; revision <= 3; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + expect(subscribe).toHaveBeenCalledTimes(3) + + // This rearm actually took: no `end` follows, so the stream is still up well + // past the window a dead PTY's teardown would have landed in. + subscribe.mockClear() + subscriptionsRef.current.set('terminal-1', () => {}) + await act(async () => { + renderer?.update( + createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 4 }) + ) + }) + await act(async () => { + vi.advanceTimersByTime(5_000) + }) + await playDeadPtyRoundTrip(5) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + } finally { + vi.useRealTimers() + } + }) + + it('keeps an absence marker observed before the budget ran out', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + for (let revision = 1; revision <= 2; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + // The handle went away and came back with budget still left. Spending the marker + // on that check left nothing to trade once the budget did run out — and a handle + // the host keeps listing never goes absent again, so the composer locked for good. + await act(async () => { + notifyListedHandlesRef.current(new Set()) + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + await playDeadPtyRoundTrip(3) + subscribe.mockClear() + await playDeadPtyRoundTrip(4) + expect(subscribe).not.toHaveBeenCalled() + + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + + expect(subscribe).toHaveBeenCalledWith('terminal-1') + }) + + it('keeps asking until a tab snapshot replaces the exhausted handle', async () => { + await act(async () => { + renderer = create(createElement(Harness, { showNativeChat: true })) + }) + for (let revision = 1; revision <= 3; revision += 1) { + await playDeadPtyRoundTrip(revision) + } + // Exhausted but still listed: the host may yet answer, so nothing to recover from. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-1'])) + }) + expect(hasTabsRecoveryNeedRef.current()).toBe(false) + + // Gone AND out of rearms: a graph reload reminted the id, so only a fresh tab + // snapshot carries a handle the composer can use. + await act(async () => { + notifyListedHandlesRef.current(new Set(['terminal-2'])) + }) + expect(hasTabsRecoveryNeedRef.current()).toBe(true) + // An equal cached tab snapshot must leave recovery pending. + expect(hasTabsRecoveryNeedRef.current()).toBe(true) + + await act(async () => { + renderer?.update( + createElement(Harness, { + showNativeChat: true, + activeHandle: 'terminal-2', + leaseReady: false, + streamRevision: 4 + }) + ) + }) + expect(hasTabsRecoveryNeedRef.current()).toBe(false) + }) + it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => { const original = console.error const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { diff --git a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts index c45980ae6e3..0f0a642a149 100644 --- a/mobile/src/session/use-mobile-native-chat-terminal-stream.ts +++ b/mobile/src/session/use-mobile-native-chat-terminal-stream.ts @@ -1,21 +1,60 @@ -import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from 'react' import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream' +/** Enough to ride out a transient teardown without spinning on a dead PTY. */ +const MAX_REARM_ATTEMPTS = 3 + +/** A dead PTY's `end` follows its `subscribed` within one host round trip, so a + * stream still up this long after the ack is one that actually recovered. */ +const HEALTHY_STREAM_PROOF_MS = 5_000 + /** Pauses the active terminal stream while native chat covers its mounted WebView, * then resumes from a fresh scrollback snapshot when terminal view returns. */ export function useMobileNativeChatTerminalStream(args: { showNativeChat: boolean activeHandle: string | null activeTabType: string | null + /** Reactive lease state — losing it must re-run this effect, since the refs it + * reads for stream liveness are invisible to React. */ + leaseReady: boolean + /** Bumped whenever a covered stream is torn down. The lease alone can't carry + * that signal: a dead PTY can emit `end` with no preceding `subscribed`, so + * clearing an already-absent lease is a no-op and nothing would re-run. */ + streamRevision: number subscriptionsRef: MutableRefObject void>> subscribingRef: MutableRefObject> webReadyRef: MutableRefObject> initializedRef: MutableRefObject> subscribe: (handle: string) => void unsubscribe: (handle: string) => void -}): (handle: string, wasAlreadyReady: boolean) => void { +}): { + notifyWebReady: (handle: string, wasAlreadyReady: boolean) => void + notifyListedHandles: (liveHandles: ReadonlySet) => void + /** True while an exhausted, absent handle still needs a replacement tab snapshot. */ + hasTabsRecoveryNeed: () => boolean +} { const coveredHandleRef = useRef(null) + const rearmAttemptsRef = useRef>(new Map()) + /** Handles `terminal.list` has omitted since we last saw them. Only a handle that + * actually went away and came back earns a fresh rearm budget. */ + const absentSinceExhaustionRef = useRef>(new Set()) + /** Pending proof that a rearmed stream outlived the dead-PTY teardown window. */ + const healthyStreamProofRef = useRef<{ + handle: string + timer: ReturnType + } | null>(null) const [webReadyRevision, setWebReadyRevision] = useState(0) + const [rearmBudgetRevision, setRearmBudgetRevision] = useState(0) + const forgetRearmState = useCallback((handle: string) => { + rearmAttemptsRef.current.delete(handle) + absentSinceExhaustionRef.current.delete(handle) + }, []) + const cancelHealthyStreamProof = useCallback(() => { + if (healthyStreamProofRef.current) { + clearTimeout(healthyStreamProofRef.current.timer) + healthyStreamProofRef.current = null + } + }, []) const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => { // Why: ordinary WebView startups must not rerender the large session route; // only readiness that can release a native-chat lease needs reconciliation. @@ -23,9 +62,54 @@ export function useMobileNativeChatTerminalStream(args: { setWebReadyRevision((revision) => revision + 1) } }, []) + const notifyListedHandles = useCallback( + (liveHandles: ReadonlySet) => { + // Why: the rearm budget bounds one teardown, not the handle's lifetime. A covered + // handle the host drops and then reports again may have a live PTY once more, so + // spend a fresh budget instead of leaving the composer locked until leave-chat + // (#10681). + // + // The absence is required, not incidental: a dead PTY that stays listed answers + // every subscribe with `subscribed`+`end`, so refilling merely because the handle + // is present now would hand that loop a new budget on each list refresh and undo + // the bound this hook exists to enforce. + const handle = coveredHandleRef.current + if (handle == null) { + return + } + if (!liveHandles.has(handle)) { + absentSinceExhaustionRef.current.add(handle) + return + } + // Why: consume the absence marker only when it actually buys a refill. Spending + // it on a below-threshold check left the handle with nothing to trade once the + // budget did run out, and a still-listed handle never goes absent again — the + // permanent lock the marker exists to prevent. + if ((rearmAttemptsRef.current.get(handle) ?? 0) < MAX_REARM_ATTEMPTS) { + return + } + if (!absentSinceExhaustionRef.current.delete(handle)) { + return + } + forgetRearmState(handle) + setRearmBudgetRevision((revision) => revision + 1) + }, + [forgetRearmState] + ) + const hasTabsRecoveryNeed = useCallback((): boolean => { + // Keep polling until a replacement handle arrives; an equal cached snapshot + // must not consume recovery and strand the composer on the dead handle. + const handle = coveredHandleRef.current + return ( + handle != null && + absentSinceExhaustionRef.current.has(handle) && + (rearmAttemptsRef.current.get(handle) ?? 0) >= MAX_REARM_ATTEMPTS + ) + }, []) useEffect(() => { const handle = args.activeHandle if (coveredHandleRef.current && coveredHandleRef.current !== handle) { + forgetRearmState(coveredHandleRef.current) coveredHandleRef.current = null } const streamActive = @@ -39,11 +123,53 @@ export function useMobileNativeChatTerminalStream(args: { streamCovered: coveredHandleRef.current === handle, webViewReady: handle != null && args.webReadyRef.current.has(handle) }) - if (!handle || action === 'none') { + const streamHolding = action === 'none' && streamActive && coveredHandleRef.current === handle + // Any pass that isn't "this covered stream is still up" invalidates a pending + // proof — the stream it was vouching for is gone or being replaced. + if (!streamHolding || healthyStreamProofRef.current?.handle !== handle) { + cancelHealthyStreamProof() + } + if (!handle) { + return + } + if (action === 'none') { + // Why: `subscribed` is the FIRST half of a dead PTY's reply (`subscribed` then + // `end`), and it re-runs this effect with the stream momentarily up. Clearing + // the budget there handed every dead-PTY pass a fresh one, so the resubscribe + // loop this hook exists to bound ran forever. Only a stream still up after the + // `end` would have landed proves the last rearm took. + if (streamHolding && !healthyStreamProofRef.current && rearmAttemptsRef.current.has(handle)) { + healthyStreamProofRef.current = { + handle, + timer: setTimeout(() => { + healthyStreamProofRef.current = null + forgetRearmState(handle) + }, HEALTHY_STREAM_PROOF_MS) + } + } + return + } + if (action === 'rearm') { + // Why: the host answers a handle whose PTY is gone with `subscribed`+`end`, + // which drops the lease again — an unbounded resubscribe loop, each pass + // costing a 10s host wait and a desktop tab-mount request (#10681). Give up + // after a few tries and leave the composer honestly locked. + const attempts = rearmAttemptsRef.current.get(handle) ?? 0 + if (attempts >= MAX_REARM_ATTEMPTS) { + return + } + args.subscribe(handle) + // Why: a subscribe turned away by its own gates (no client, no webview yet) + // never reached the host, so charging it an attempt would spend the budget on + // nothing and could exhaust it before a single real rearm was tried. + if (args.subscribingRef.current.has(handle) || args.subscriptionsRef.current.has(handle)) { + rearmAttemptsRef.current.set(handle, attempts + 1) + } return } if (action === 'pause') { coveredHandleRef.current = handle + forgetRearmState(handle) // Why: returning to terminal must accept the fresh scrollback snapshot; // the stream was paused while chat covered output that xterm never saw. args.initializedRef.current.delete(handle) @@ -64,13 +190,25 @@ export function useMobileNativeChatTerminalStream(args: { args.activeHandle, args.activeTabType, args.initializedRef, + args.leaseReady, args.showNativeChat, + args.streamRevision, args.subscribe, args.subscribingRef, args.subscriptionsRef, args.unsubscribe, args.webReadyRef, + cancelHealthyStreamProof, + forgetRearmState, + rearmBudgetRevision, webReadyRevision ]) - return notifyWebReady + useEffect(() => cancelHealthyStreamProof, [cancelHealthyStreamProof]) + // Why memoized: the session route keeps this object in callback dep arrays, and a + // fresh literal per render would rebuild them on every keystroke. All three members + // are already stable, so this reference never changes. + return useMemo( + () => ({ notifyWebReady, notifyListedHandles, hasTabsRecoveryNeed }), + [hasTabsRecoveryNeed, notifyListedHandles, notifyWebReady] + ) } diff --git a/mobile/src/session/use-mobile-session-image-attachments.ts b/mobile/src/session/use-mobile-session-image-attachments.ts index 7428bde224d..ad4343ac87d 100644 --- a/mobile/src/session/use-mobile-session-image-attachments.ts +++ b/mobile/src/session/use-mobile-session-image-attachments.ts @@ -24,12 +24,16 @@ type Args = { readonly getActiveWorktreeConnectionId: () => Promise readonly beforeTerminalSend: (terminal: string) => Promise /** Outcome-preserving so an ambiguous ('unknown') delivery after an image - * paste can mark the terminal input for healing (#10228). */ + * paste can mark the terminal input for healing (#10228). Takes the image + * send's budget so the paste and this text body share one `sending` window. */ readonly nativeChatBaseSend: ( text: string, - images?: string[] + images?: string[], + deadline?: number ) => Promise readonly showToast: (message: string, durationMs?: number) => void + /** Native-chat send failures — rendered in the composer's inline banner. */ + readonly onNativeChatSendError: (message: string) => void readonly onSuccess: () => void readonly onError: () => void } @@ -51,6 +55,7 @@ export function useMobileSessionImageAttachments({ beforeTerminalSend, nativeChatBaseSend, showToast, + onNativeChatSendError, onSuccess, onError }: Args): { @@ -79,6 +84,7 @@ export function useMobileSessionImageAttachments({ scopeKey: nativeChatScopeKey, enabled: nativeChatInputLeaseReady, showToast, + onSendError: onNativeChatSendError, baseSend: nativeChatBaseSend, onAttachSuccess: onSuccess, onError diff --git a/mobile/src/session/use-mobile-session-tabs-fetch-reporting.ts b/mobile/src/session/use-mobile-session-tabs-fetch-reporting.ts new file mode 100644 index 00000000000..13bbc7cb7a0 --- /dev/null +++ b/mobile/src/session/use-mobile-session-tabs-fetch-reporting.ts @@ -0,0 +1,28 @@ +import { useMemo, type MutableRefObject } from 'react' +import type { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics' + +type DiagnosticTabsSnapshot = Parameters[0] + +/** Forwards session-tabs fetch outcomes to the screen's diagnostics recorder. + * Split out of the session route so the reconciliation wiring there stays a + * single call rather than five one-line callbacks. */ +export function useMobileSessionTabsFetchReporting(args: { + worktreeId: string + diagnosticsRef: MutableRefObject +}): { + onFetchStarted: () => void + onFetchSucceeded: (result: Result) => void + onFetchFailed: (code: string) => void + onFetchErrored: (error: unknown) => void +} { + const { worktreeId, diagnosticsRef } = args + return useMemo( + () => ({ + onFetchStarted: () => diagnosticsRef.current.tabsFetchStarted(worktreeId), + onFetchSucceeded: (result: Result) => diagnosticsRef.current.tabsFetchSucceeded(result), + onFetchFailed: (code: string) => diagnosticsRef.current.tabsFetchFailed(code), + onFetchErrored: (error: unknown) => diagnosticsRef.current.tabsFetchErrored(error) + }), + [diagnosticsRef, worktreeId] + ) +} diff --git a/mobile/src/session/use-native-chat-action-outcomes.ts b/mobile/src/session/use-native-chat-action-outcomes.ts new file mode 100644 index 00000000000..51a6f831b5a --- /dev/null +++ b/mobile/src/session/use-native-chat-action-outcomes.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send' + +/** Wraps a chat card action so an accepted write also retires the route's held + * failure banner. + * + * The banner outlives the write that raised it, so every accepted action has to + * retire it — not just the composer send, which was the only one that did. A + * delivered answer or permission reply otherwise sits under a stale "not sent" + * until the hold timer happens to expire. */ +export function useNativeChatAcceptedAction( + action: (...params: Params) => Promise, + onAccepted: () => void +): (...params: Params) => Promise { + return useCallback( + async (...params: Params): Promise => { + const accepted = await action(...params) + if (accepted) { + onAccepted() + } + return accepted + }, + [action, onAccepted] + ) +} + +/** Boolean surface for callers with no pre-pasted input: 'unknown' stays true + * (the send usually landed; the optimistic echo is already held unconfirmed). */ +export function useNativeChatSentFlag( + send: (text: string, images?: string[]) => Promise +): (text: string, images?: string[]) => Promise { + return useCallback( + async (text: string, images?: string[]): Promise => + (await send(text, images)) !== 'rejected', + [send] + ) +} diff --git a/mobile/src/transport/mobile-relay-rpc-session.ts b/mobile/src/transport/mobile-relay-rpc-session.ts index a0e71a85c1b..a614e833387 100644 --- a/mobile/src/transport/mobile-relay-rpc-session.ts +++ b/mobile/src/transport/mobile-relay-rpc-session.ts @@ -7,6 +7,7 @@ import { MobileRelayE2eeLink } from './mobile-relay-e2ee-link' import { MobileRelayRpcStreams } from './mobile-relay-rpc-streams' import { MobileE2EEAuthenticationError } from './mobile-e2ee-v2-physical-channel' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' import { isRpcResponse } from './rpc-response-shape' import type { RpcClient } from './rpc-client' import type { ConnectionState, RpcResponse } from './types' @@ -75,8 +76,9 @@ export function connectMobileRelayRpcSession(args: { const client: MobileRelayRpcSession = { async sendRequest(method, params, options) { - await waitForConnected(options?.timeoutMs) - return sendRpc(method, params, options?.timeoutMs) + const budget = openRpcRequestBudget(options) + await waitForConnected(budget.timeoutMs) + return sendRpc(method, params, resolvePostConnectRequestTimeout(budget, requestTimeoutMs)) }, subscribe(method, params, listener, options) { diff --git a/mobile/src/transport/rpc-client-request-deadline.test.ts b/mobile/src/transport/rpc-client-request-deadline.test.ts new file mode 100644 index 00000000000..2ed349f375a --- /dev/null +++ b/mobile/src/transport/rpc-client-request-deadline.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { connect } from './rpc-client' + +vi.mock('./e2ee', () => ({ + generateKeyPair: () => ({ + publicKey: new Uint8Array(32), + secretKey: new Uint8Array(32) + }), + deriveSharedKey: () => new Uint8Array(32), + publicKeyFromBase64: () => new Uint8Array(32), + publicKeyToBase64: () => 'client-public-key', + encrypt: (plaintext: string) => `encrypted:${plaintext}`, + decrypt: (raw: string) => raw.replace(/^encrypted:/, ''), + decryptBytes: (bytes: Uint8Array) => bytes +})) + +class MockWebSocket { + static CONNECTING = 0 + static OPEN = 1 + static CLOSED = 3 + + readonly CONNECTING = MockWebSocket.CONNECTING + readonly OPEN = MockWebSocket.OPEN + readonly CLOSED = MockWebSocket.CLOSED + + readyState = MockWebSocket.CONNECTING + onopen: (() => void) | null = null + onclose: (() => void) | null = null + onmessage: ((event: { data: unknown }) => void) | null = null + sent: string[] = [] + close = vi.fn(() => { + if (this.readyState === MockWebSocket.CLOSED) { + return + } + this.readyState = MockWebSocket.CLOSED + this.onclose?.() + }) + + constructor(readonly endpoint: string) { + mockSockets.push(this) + } + + send(payload: string): void { + this.sent.push(payload) + } + + open(): void { + this.readyState = MockWebSocket.OPEN + this.onopen?.() + this.receive(JSON.stringify({ type: 'e2ee_ready' })) + this.receive('encrypted:{"type":"e2ee_authenticated"}') + } + + receive(payload: unknown): void { + this.onmessage?.({ data: payload }) + } +} + +const mockSockets: MockWebSocket[] = [] +const originalWebSocket = globalThis.WebSocket + +/** Latest settled state of a request, so a timeout can be observed without + * awaiting a promise that would reject under fake timers. */ +function track(request: Promise): { read: () => string } { + let outcome = 'pending' + request.then( + () => { + outcome = 'resolved' + }, + (error: Error) => { + outcome = error.message + } + ) + return { read: () => outcome } +} + +describe('mobile rpc-client request deadline', () => { + beforeEach(() => { + vi.useFakeTimers() + mockSockets.length = 0 + globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket + }) + + afterEach(() => { + vi.useRealTimers() + globalThis.WebSocket = originalWebSocket + }) + + it('spends one deadline across connect-wait and request, not one each', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + const request = client.sendRequest( + 'terminal.send', + { terminal: 't1', text: 'hi' }, + { timeoutMs: 5_000, budgetSpansConnect: true } + ) + const outcome = track(request) + + try { + // The first reconnect delay burns 500ms of the caller's 5s budget. + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + + await vi.advanceTimersByTimeAsync(4_499) + expect(outcome.read()).toBe('pending') + + // Restarting the budget after connecting would still have 500ms to go here. + await vi.advanceTimersByTimeAsync(1) + expect(outcome.read()).toBe('Request timed out: terminal.send') + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('leaves a caller that did not opt in on the post-connect clock', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + const socket = mockSockets[0]! + socket.open() + socket.close() + + // A pre-existing caller's budget was sized against the request phase alone; + // the connect wait must not eat into it. + const request = client.sendRequest( + 'speech.dictation.finish', + { dictationId: 'd1' }, + { timeoutMs: 5_000 } + ) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(500) + mockSockets[1]!.open() + await vi.advanceTimersByTimeAsync(0) + + // A shared deadline would already have fired at 4_500 here. + await vi.advanceTimersByTimeAsync(4_999) + expect(outcome.read()).toBe('pending') + + await vi.advanceTimersByTimeAsync(1) + expect(outcome.read()).toBe('Request timed out: speech.dictation.finish') + } finally { + client.close() + await request.catch(() => undefined) + } + }) + + it('never floors a sub-second request timeout above what the caller asked for', async () => { + const client = connect('ws://desktop.invalid', 'token', 'server-key') + mockSockets[0]!.open() + + // Under the 1s minimum: the floor clamps to 400ms rather than stretching it. + const request = client.sendRequest( + 'terminal.send', + { terminal: 't1', text: 'hi' }, + { timeoutMs: 400, budgetSpansConnect: true } + ) + const outcome = track(request) + + try { + await vi.advanceTimersByTimeAsync(399) + expect(outcome.read()).toBe('pending') + await vi.advanceTimersByTimeAsync(1) + expect(outcome.read()).toBe('Request timed out: terminal.send') + } finally { + client.close() + await request.catch(() => undefined) + } + }) +}) diff --git a/mobile/src/transport/rpc-client.ts b/mobile/src/transport/rpc-client.ts index 9b0b6246f1c..9cf08483a9d 100644 --- a/mobile/src/transport/rpc-client.ts +++ b/mobile/src/transport/rpc-client.ts @@ -29,6 +29,7 @@ import { } from './rpc-client-terminal-subscription' import { describeSocketEvent } from './socket-event-debug' import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity' +import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget' import { isRpcResponse } from './rpc-response-shape' import { websocketPayloadToUint8 } from './websocket-payload-bytes' @@ -43,8 +44,16 @@ type ConnectWaiter = { timeout: ReturnType | null } -type SendRequestOptions = { +export type SendRequestOptions = { timeoutMs?: number + /** Spend `timeoutMs` across connect-wait AND the request instead of giving each + * phase its own. Interactive chat writes need it: they run as sequential loops + * under one shared budget, so a per-phase clock lets the composer sit `sending` + * for a multiple of the stated ceiling. Off by default — the long-running + * callers (worktree create, dictation finish, credit reset) sized their budgets + * against the post-connect clock, and squeezing them to the floor after a slow + * reconnect would fail sends that used to land. */ + budgetSpansConnect?: boolean } type SubscribeOptions = { @@ -101,6 +110,9 @@ const AUTH_RETRY_BUDGET = 3 // Why: a desktop that regenerated its E2EE keypair sends an e2ee_error we can't decrypt — the 4001 close code is the only surviving auth-failure signal. const UNAUTHORIZED_CLOSE_CODE = 4001 const REQUEST_TIMEOUT_MS = 30_000 +// Why: an explicit `timeoutMs` is one budget for the whole call. If the connect wait +// ate nearly all of it, still give the written frame a moment to be answered rather +// than arming a 1ms timer. const CONNECT_TIMEOUT_MS = 12_000 const HANDSHAKE_TIMEOUT_MS = 5_000 // Why: RN may not expose WebSocket.readyState constants, but the CONNECTING protocol value (0) is stable across runtimes. @@ -985,7 +997,8 @@ export function connect( params?: unknown, options?: SendRequestOptions ): Promise { - const waitStart = Date.now() + const budget = openRpcRequestBudget(options) + const waitStart = budget.startedAt const wasConnected = state === 'connected' await waitForConnected(options?.timeoutMs) if (!wasConnected) { @@ -997,7 +1010,7 @@ export function connect( return new Promise((resolve, reject) => { const id = nextId() - const timeoutMs = options?.timeoutMs ?? REQUEST_TIMEOUT_MS + const timeoutMs = resolvePostConnectRequestTimeout(budget, REQUEST_TIMEOUT_MS) const timeout = setTimeout(() => { pending.delete(id) console.log('[net] sendRequest TIMEOUT', { diff --git a/mobile/src/transport/rpc-request-budget.ts b/mobile/src/transport/rpc-request-budget.ts new file mode 100644 index 00000000000..aecbe023cfd --- /dev/null +++ b/mobile/src/transport/rpc-request-budget.ts @@ -0,0 +1,38 @@ +type RpcRequestBudgetOptions = { + timeoutMs?: number + budgetSpansConnect?: boolean +} + +export type RpcRequestBudget = { + startedAt: number + timeoutMs?: number + deadline: number | null +} + +export const RPC_REQUEST_MIN_ACK_MS = 1_000 + +export function openRpcRequestBudget( + options?: RpcRequestBudgetOptions, + now = Date.now() +): RpcRequestBudget { + const timeoutMs = options?.timeoutMs + return { + startedAt: now, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + deadline: options?.budgetSpansConnect && timeoutMs !== undefined ? now + timeoutMs : null + } +} + +export function resolvePostConnectRequestTimeout( + budget: RpcRequestBudget, + fallbackMs: number, + now = Date.now() +): number { + if (budget.deadline === null) { + return budget.timeoutMs ?? fallbackMs + } + return Math.max( + Math.min(RPC_REQUEST_MIN_ACK_MS, budget.deadline - budget.startedAt), + budget.deadline - now + ) +} diff --git a/package.json b/package.json index 0ba68ca2941..1df03908168 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orca", - "version": "1.4.157-rc.0", + "version": "1.4.160-rc.0", "description": "Next-gen IDE for parallel agentic development", "homepage": "https://github.com/stablyai/orca", "author": "stablyai", @@ -11,7 +11,7 @@ "main": "./out/main/index.js", "scripts": { "format": "oxfmt --write .", - "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", + "lint": "oxlint && pnpm run lint:switch-exhaustiveness && node config/scripts/check-styled-scrollbars.mjs && pnpm run check:quadratic-buffer-concat && pnpm run check:reliability-gates && pnpm run check:max-lines-ratchet && pnpm run verify:bundled-skill-guides && pnpm run verify:skill-bundle-manifest && pnpm run verify:localization-catalog && pnpm run verify:localization-coverage", "lint:react-doctor": "oxlint --config config/oxlint-react-doctor.json", "lint:react-doctor:changed": "node config/scripts/lint-react-doctor-changed.mjs", "lint:switch-exhaustiveness": "oxlint --type-aware --config config/oxlint-switch-exhaustiveness.json src/main src/preload src/shared src/relay src/cli src/renderer/src config tests --quiet", @@ -19,6 +19,7 @@ "test": "node config/scripts/ensure-native-runtime.mjs --runtime=node && vitest run --config config/vitest.config.ts", "test:repro:remote-agent-session": "pnpm run build:cli && pnpm run build:electron-vite && node config/scripts/remote-agent-session-authority-repro.mjs", "check:styled-scrollbars": "node config/scripts/check-styled-scrollbars.mjs", + "check:quadratic-buffer-concat": "node config/scripts/check-quadratic-buffer-concat.mjs", "check:reliability-gates": "node config/scripts/check-reliability-gates.mjs", "check:max-lines-ratchet": "node config/scripts/check-max-lines-ratchet.mjs", "check:feature-wall-assets": "node config/scripts/check-feature-wall-assets.mjs", @@ -64,17 +65,19 @@ "audit:localization": "node config/scripts/audit-localization-coverage.mjs", "build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/verify-cli-bin.mjs --fix-executable --fix-package-json && node config/scripts/install-dev-cli.mjs", "build:electron-vite": "node config/scripts/run-electron-vite-build.mjs", + "build:electron-vite:parallel": "node config/scripts/run-electron-vite-targets-in-parallel.mjs", "build:web": "node config/scripts/run-vite-web-build.mjs && node config/scripts/verify-web-build.mjs", - "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", + "build:web-from-renderer": "node config/scripts/project-renderer-web-client.mjs && node config/scripts/verify-web-build.mjs", + "build:desktop": "pnpm run typecheck && pnpm run build:relay && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", "build": "pnpm run build:desktop && pnpm run build:native", - "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web", + "build:release": "pnpm run build:relay && pnpm run build:native && pnpm run verify:computer-native && pnpm run build:cli && pnpm run build:electron-vite && pnpm run build:web-from-renderer", "postinstall": "node config/scripts/rebuild-native-deps.mjs", "rebuild:electron": "node config/scripts/rebuild-native-deps.mjs", "rebuild:node": "pnpm rebuild node-pty", "build:unpack": "pnpm run build && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --dir", "build:win": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --win", "build:icons": "bash resources/icon-source/generate.sh", - "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --mac", + "build:mac": "pnpm run build:desktop && pnpm run build:computer-macos && pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && node config/scripts/build-mac-local.mjs", "build:mac:release": "node config/scripts/verify-macos-release-env.mjs && ORCA_MAC_RELEASE=1 pnpm run build:desktop && ORCA_MAC_RELEASE=1 pnpm run build:computer-macos && ORCA_MAC_RELEASE=1 pnpm run build:notification-status-macos && pnpm run ensure:electron-runtime && ORCA_MAC_RELEASE=1 electron-builder --config config/electron-builder.config.cjs --mac", "build:linux": "pnpm run build:desktop && pnpm run ensure:electron-runtime && electron-builder --config config/electron-builder.config.cjs --linux AppImage deb", "test:e2e": "pnpm run ensure:electron-runtime && npx playwright test --config tests/playwright.config.ts --project=electron-headless", @@ -223,7 +226,7 @@ "typescript": "^7.0.2", "typescript-api": "npm:typescript@6.0.3", "unified": "^11.0.5", - "vite": "^7.3.6", + "vite": "npm:rolldown-vite@7.3.1", "vitest": "^4.1.5", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.3.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea1672e8937..109f230e41e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ patchedDependencies: hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch '@xterm/xterm@6.1.0-beta.287': - hash: 9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c + hash: 8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3 path: config/patches/@xterm__xterm@6.1.0-beta.287.patch node-pty@1.1.0: hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8 @@ -46,7 +46,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.287 - version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/headless': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -122,7 +122,7 @@ importers: version: 2.1.14(@playwright/test@1.59.1)(zod@4.4.3) '@tailwindcss/vite': specifier: ^4.2.4 - version: 4.2.4(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) '@tanstack/react-virtual': specifier: ^3.13.24 version: 3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -203,28 +203,28 @@ importers: version: 8.18.1 '@vitejs/plugin-react': specifier: ^5.2.0 - version: 5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) '@xterm/addon-fit': specifier: 0.12.0-beta.287 - version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-ligatures': specifier: 0.11.0-beta.287 - version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-search': specifier: 0.17.0-beta.287 - version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-unicode11': specifier: 0.10.0-beta.287 - version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-web-links': specifier: 0.13.0-beta.287 - version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/addon-webgl': specifier: 0.20.0-beta.286 - version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)) + version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)) '@xterm/xterm': specifier: 6.1.0-beta.287 - version: 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + version: 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -248,7 +248,7 @@ importers: version: 26.15.3(dmg-builder@26.15.3) electron-vite: specifier: ^5.0.0 - version: 5.0.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) emoji-picker-react: specifier: ^4.19.1 version: 4.19.1(react@19.2.7) @@ -373,11 +373,11 @@ importers: specifier: ^11.0.5 version: 11.0.5 vite: - specifier: ^7.3.6 - version: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + version: 4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) vscode-oniguruma: specifier: ^2.0.1 version: 2.0.1 @@ -663,318 +663,171 @@ packages: engines: {node: '>=14.14'} hasBin: true + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/netbsd-arm64@0.25.12': resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/openbsd-arm64@0.25.12': resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openharmony-arm64@0.25.12': resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} cpu: [x64] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1176,6 +1029,12 @@ packages: resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -1220,6 +1079,13 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@oxc-project/runtime@0.101.0': + resolution: {integrity: sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.101.0': + resolution: {integrity: sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==} + '@oxfmt/binding-android-arm-eabi@0.52.0': resolution: {integrity: sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2301,146 +2167,92 @@ packages: resolution: {integrity: sha512-L2eAxN46Vq2Ss3nDegrH7wQVMeWH03ahawp+OdzUtQWqL3cq6Bt149q9XhY3cWc9fJsxuWjLfCn+3T9uApIlBA==} hasBin: true - '@rolldown/pluginutils@1.0.0-rc.3': - resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} - - '@rollup/rollup-android-arm-eabi@4.60.3': - resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.60.3': - resolution: {integrity: sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==} + '@rolldown/binding-android-arm64@1.0.0-beta.53': + resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.60.3': - resolution: {integrity: sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==} + '@rolldown/binding-darwin-arm64@1.0.0-beta.53': + resolution: {integrity: sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.60.3': - resolution: {integrity: sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==} + '@rolldown/binding-darwin-x64@1.0.0-beta.53': + resolution: {integrity: sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.60.3': - resolution: {integrity: sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.60.3': - resolution: {integrity: sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==} + '@rolldown/binding-freebsd-x64@1.0.0-beta.53': + resolution: {integrity: sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.60.3': - resolution: {integrity: sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53': + resolution: {integrity: sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.60.3': - resolution: {integrity: sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==} - cpu: [arm] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-arm64-gnu@4.60.3': - resolution: {integrity: sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53': + resolution: {integrity: sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.60.3': - resolution: {integrity: sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==} + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53': + resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.60.3': - resolution: {integrity: sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==} - cpu: [loong64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-loong64-musl@4.60.3': - resolution: {integrity: sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==} - cpu: [loong64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-ppc64-gnu@4.60.3': - resolution: {integrity: sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-ppc64-musl@4.60.3': - resolution: {integrity: sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==} - cpu: [ppc64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-riscv64-gnu@4.60.3': - resolution: {integrity: sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-riscv64-musl@4.60.3': - resolution: {integrity: sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@rollup/rollup-linux-s390x-gnu@4.60.3': - resolution: {integrity: sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@rollup/rollup-linux-x64-gnu@4.60.3': - resolution: {integrity: sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==} + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53': + resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.60.3': - resolution: {integrity: sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==} + '@rolldown/binding-linux-x64-musl@1.0.0-beta.53': + resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rollup/rollup-openbsd-x64@4.60.3': - resolution: {integrity: sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==} - cpu: [x64] - os: [openbsd] - - '@rollup/rollup-openharmony-arm64@4.60.3': - resolution: {integrity: sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==} + '@rolldown/binding-openharmony-arm64@1.0.0-beta.53': + resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.60.3': - resolution: {integrity: sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==} + '@rolldown/binding-wasm32-wasi@1.0.0-beta.53': + resolution: {integrity: sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53': + resolution: {integrity: sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.60.3': - resolution: {integrity: sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.60.3': - resolution: {integrity: sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==} + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53': + resolution: {integrity: sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.60.3': - resolution: {integrity: sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==} - cpu: [x64] - os: [win32] + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} '@sanity/diff-match-patch@3.2.0': resolution: {integrity: sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww==} @@ -2843,6 +2655,9 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -4104,11 +3919,6 @@ packages: engines: {node: '>=18'} hasBin: true - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -5836,9 +5646,50 @@ packages: robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} - rollup@4.60.3: - resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} + rolldown-vite@7.3.1: + resolution: {integrity: sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w==} + engines: {node: ^20.19.0 || >=22.12.0} + deprecated: Use this package to migrate from Vite 7 to Vite 8. For the most recent updates, migrate to Vite 8 once you're ready. + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.53: + resolution: {integrity: sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==} + engines: {node: ^20.19.0 || >=22.12.0} hasBin: true rope-sequence@1.3.4: @@ -6389,46 +6240,6 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - vite@7.3.6: - resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vitest@4.1.5: resolution: {integrity: sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -7006,162 +6817,100 @@ snapshots: - supports-color optional: true - '@esbuild/aix-ppc64@0.25.12': + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/android-arm64@0.25.12': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm@0.25.12': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-x64@0.25.12': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.25.12': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.25.12': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.25.12': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.25.12': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.25.12': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm@0.25.12': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-ia32@0.25.12': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-loong64@0.25.12': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.25.12': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.25.12': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.25.12': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-s390x@0.25.12': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-x64@0.25.12': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - '@esbuild/netbsd-arm64@0.25.12': optional: true - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.25.12': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - '@esbuild/openbsd-arm64@0.25.12': optional: true - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.25.12': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - '@esbuild/openharmony-arm64@0.25.12': optional: true - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.25.12': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.25.12': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-ia32@0.25.12': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-x64@0.25.12': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -7364,6 +7113,13 @@ snapshots: '@napi-rs/canvas-win32-x64-msvc': 0.1.100 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': @@ -7403,6 +7159,10 @@ snapshots: '@open-draft/until@2.1.0': optional: true + '@oxc-project/runtime@0.101.0': {} + + '@oxc-project/types@0.101.0': {} + '@oxfmt/binding-android-arm-eabi@0.52.0': optional: true @@ -8385,83 +8145,54 @@ snapshots: smol-toml: 1.6.1 tinyexec: 1.1.2 + '@rolldown/binding-android-arm64@1.0.0-beta.53': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.53': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.53': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.53': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.53': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.53': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.53': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.53': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.53': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.53': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.53(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.53': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.53': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.53': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/rollup-android-arm-eabi@4.60.3': - optional: true - - '@rollup/rollup-android-arm64@4.60.3': - optional: true - - '@rollup/rollup-darwin-arm64@4.60.3': - optional: true - - '@rollup/rollup-darwin-x64@4.60.3': - optional: true - - '@rollup/rollup-freebsd-arm64@4.60.3': - optional: true - - '@rollup/rollup-freebsd-x64@4.60.3': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.60.3': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.60.3': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-loong64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-ppc64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.60.3': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.60.3': - optional: true - - '@rollup/rollup-linux-x64-musl@4.60.3': - optional: true - - '@rollup/rollup-openbsd-x64@4.60.3': - optional: true - - '@rollup/rollup-openharmony-arm64@4.60.3': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.60.3': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.60.3': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.60.3': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.60.3': - optional: true - '@sanity/diff-match-patch@3.2.0': {} '@sec-ant/readable-stream@0.4.1': {} @@ -8561,12 +8292,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 - '@tailwindcss/vite@4.2.4(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@tailwindcss/vite@4.2.4(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 tailwindcss: 4.2.4 - vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) '@tanstack/react-virtual@3.13.24(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: @@ -8850,6 +8581,11 @@ snapshots: minimatch: 10.2.5 path-browserify: 1.0.1 + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -9168,7 +8904,7 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@5.2.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitejs/plugin-react@5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.7) @@ -9176,7 +8912,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -9189,14 +8925,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.3(@types/node@25.9.5)(typescript@7.0.2) - vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -9224,39 +8960,39 @@ snapshots: '@xmldom/xmldom@0.8.13': {} - '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) lru-cache: 11.5.1 opentype.js: 2.0.0 - '@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) - '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c))': + '@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3))': dependencies: - '@xterm/xterm': 6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c) + '@xterm/xterm': 6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3) '@xterm/headless@6.1.0-beta.287': {} - '@xterm/xterm@6.1.0-beta.287(patch_hash=9c1de9931d86864923ff53bc9d64474a86478085ac81ea4e432648c7e23d702c)': {} + '@xterm/xterm@6.1.0-beta.287(patch_hash=8a337bdef40a57723e23e548f6100feb49755075f58702a8ae4544ad3ae2b9d3)': {} abbrev@4.0.0: {} @@ -10047,7 +9783,7 @@ snapshots: transitivePeerDependencies: - supports-color - electron-vite@5.0.0(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + electron-vite@5.0.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.27.1(@babel/core@7.29.7) @@ -10055,7 +9791,7 @@ snapshots: esbuild: 0.25.12 magic-string: 0.30.21 picocolors: 1.1.1 - vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -10166,35 +9902,6 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - escalade@3.2.0: {} escape-html@1.0.3: {} @@ -12377,36 +12084,45 @@ snapshots: robust-predicates@3.0.3: {} - rollup@4.60.3: + rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4): dependencies: - '@types/estree': 1.0.8 + '@oxc-project/runtime': 0.101.0 + fdir: 6.5.0(picomatch@4.0.4) + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0-beta.53(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + tinyglobby: 0.2.16 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.60.3 - '@rollup/rollup-android-arm64': 4.60.3 - '@rollup/rollup-darwin-arm64': 4.60.3 - '@rollup/rollup-darwin-x64': 4.60.3 - '@rollup/rollup-freebsd-arm64': 4.60.3 - '@rollup/rollup-freebsd-x64': 4.60.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.60.3 - '@rollup/rollup-linux-arm-musleabihf': 4.60.3 - '@rollup/rollup-linux-arm64-gnu': 4.60.3 - '@rollup/rollup-linux-arm64-musl': 4.60.3 - '@rollup/rollup-linux-loong64-gnu': 4.60.3 - '@rollup/rollup-linux-loong64-musl': 4.60.3 - '@rollup/rollup-linux-ppc64-gnu': 4.60.3 - '@rollup/rollup-linux-ppc64-musl': 4.60.3 - '@rollup/rollup-linux-riscv64-gnu': 4.60.3 - '@rollup/rollup-linux-riscv64-musl': 4.60.3 - '@rollup/rollup-linux-s390x-gnu': 4.60.3 - '@rollup/rollup-linux-x64-gnu': 4.60.3 - '@rollup/rollup-linux-x64-musl': 4.60.3 - '@rollup/rollup-openbsd-x64': 4.60.3 - '@rollup/rollup-openharmony-arm64': 4.60.3 - '@rollup/rollup-win32-arm64-msvc': 4.60.3 - '@rollup/rollup-win32-ia32-msvc': 4.60.3 - '@rollup/rollup-win32-x64-gnu': 4.60.3 - '@rollup/rollup-win32-x64-msvc': 4.60.3 + '@types/node': 25.9.5 fsevents: 2.3.3 + jiti: 2.7.0 + yaml: 2.8.4 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' + + rolldown@1.0.0-beta.53(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2): + dependencies: + '@oxc-project/types': 0.101.0 + '@rolldown/pluginutils': 1.0.0-beta.53 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.53 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.53 + '@rolldown/binding-darwin-x64': 1.0.0-beta.53 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.53 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.53 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.53 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.53 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.53 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.53 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.53 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.53(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.53 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.53 + transitivePeerDependencies: + - '@emnapi/core' + - '@emnapi/runtime' rope-sequence@1.3.4: {} @@ -12997,25 +12713,10 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4): - dependencies: - esbuild: 0.28.1 - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - postcss: 8.5.14 - rollup: 4.60.3 - tinyglobby: 0.2.16 - optionalDependencies: - '@types/node': 25.9.5 - fsevents: 2.3.3 - jiti: 2.7.0 - lightningcss: 1.32.0 - yaml: 2.8.4 - - vitest@4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)): + vitest@4.1.5(@types/node@25.9.5)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(vite@7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.9.5)(typescript@7.0.2))(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -13032,7 +12733,7 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.6(@types/node@25.9.5)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4) + vite: rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.9.5 diff --git a/resources/plugins/launch/bundled-plugins.json b/resources/plugins/launch/bundled-plugins.json new file mode 100644 index 00000000000..3bc0d4e1d12 --- /dev/null +++ b/resources/plugins/launch/bundled-plugins.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "plugins": [ + { + "pluginKey": "stablyai.orca-navigation-shortcuts", + "path": "stablyai.orca-navigation-shortcuts", + "contentHash": "ce3a146bae9e121a18cb86a710973422be749a25da5a1c21a8911e2e98cc3a77" + } + ] +} diff --git a/resources/plugins/launch/orca-marketplace.json b/resources/plugins/launch/orca-marketplace.json new file mode 100644 index 00000000000..1088bdcd22f --- /dev/null +++ b/resources/plugins/launch/orca-marketplace.json @@ -0,0 +1,36 @@ +{ + "name": "Orca Official Plugins", + "owner": "stablyai", + "plugins": [ + { + "id": "stablyai.orca-portuguese", + "source": { + "kind": "git", + "url": "https://github.com/stablyai/orca-portuguese.git", + "ref": "v1.0.0" + }, + "description": "Brazilian Portuguese translations for common Orca navigation.", + "categories": ["languages", "official"] + }, + { + "id": "stablyai.orca-multipass-recipes", + "source": { + "kind": "git", + "url": "https://github.com/stablyai/orca-multipass-recipes.git", + "ref": "v1.0.0" + }, + "description": "A reviewed starter lifecycle for disposable Multipass workspaces.", + "categories": ["vm-recipes", "official"] + }, + { + "id": "stablyai.orca-navigation-shortcuts", + "source": { + "kind": "git", + "url": "https://github.com/stablyai/orca-navigation-shortcuts.git", + "ref": "v1.0.0" + }, + "description": "Command aliases and optional shortcuts for frequent Orca views.", + "categories": ["keybindings", "official"] + } + ] +} diff --git a/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json b/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json new file mode 100644 index 00000000000..53dd14d721a --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-multipass-recipes/orca-plugin.json @@ -0,0 +1,15 @@ +{ + "manifestVersion": 1, + "id": "orca-multipass-recipes", + "publisher": "stablyai", + "name": "Multipass VM Recipes", + "version": "1.0.0", + "description": "A reviewed starter lifecycle for disposable Multipass workspaces.", + "repository": "https://github.com/stablyai/orca-multipass-recipes", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "vmRecipes": [{ "path": "recipes/ubuntu-lts.json" }] + }, + "capabilities": [] +} diff --git a/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json b/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json new file mode 100644 index 00000000000..b2c60c798c6 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-multipass-recipes/recipes/ubuntu-lts.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "id": "multipass-ubuntu-lts", + "name": "Multipass Ubuntu LTS", + "description": "Creates a four-CPU disposable Ubuntu workspace with Multipass.", + "create": "multipass launch 24.04 --name \"$ORCA_VM_NAME\" --cpus 4 --memory 8G --disk 40G", + "suspend": "multipass suspend \"$ORCA_VM_NAME\"", + "resume": "multipass start \"$ORCA_VM_NAME\"", + "destroy": "multipass delete \"$ORCA_VM_NAME\" --purge" +} diff --git a/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json b/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json new file mode 100644 index 00000000000..6310d4e5476 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-navigation-shortcuts/orca-plugin.json @@ -0,0 +1,34 @@ +{ + "manifestVersion": 1, + "id": "orca-navigation-shortcuts", + "publisher": "stablyai", + "name": "Orca Navigation Shortcuts", + "version": "1.0.0", + "description": "Command aliases and optional shortcuts for frequent Orca views.", + "repository": "https://github.com/stablyai/orca-navigation-shortcuts", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "commands": [ + { "id": "open-tasks", "title": "Open Tasks", "context": "global", "action": "view.tasks" }, + { + "id": "toggle-search", + "title": "Toggle Search", + "context": "global", + "action": "sidebar.search.toggle" + }, + { + "id": "toggle-source-control", + "title": "Toggle Source Control", + "context": "global", + "action": "sidebar.sourceControl.toggle" + } + ], + "keybindings": [ + { "command": "open-tasks", "key": "Mod+Alt+T", "when": "global" }, + { "command": "toggle-search", "key": "Mod+Alt+F", "when": "global" }, + { "command": "toggle-source-control", "key": "Mod+Alt+G", "when": "global" } + ] + }, + "capabilities": [] +} diff --git a/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json b/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json new file mode 100644 index 00000000000..27945d1be25 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-portuguese/locales/pt-BR.json @@ -0,0 +1,36 @@ +{ + "settings": { + "appearance": { + "language": { + "title": "Idioma", + "description": "Escolha o idioma usado na interface do Orca.", + "system": "Sistema", + "english": "Inglês", + "chinese": "Chinês simplificado", + "korean": "Coreano", + "japanese": "Japonês", + "spanish": "Espanhol" + } + } + }, + "menu": { + "checkForUpdates": "Verificar atualizações...", + "settings": "Configurações", + "file": "Arquivo", + "exit": "Sair", + "edit": "Editar", + "appearance": "Aparência", + "view": "Visualizar", + "reload": "Recarregar", + "window": "Janela", + "help": "Ajuda", + "paste": "Colar" + }, + "tray": { + "openOrca": "Abrir o Orca", + "quit": "Encerrar", + "minimizeNotice": { + "body": "O Orca continua em execução na bandeja do sistema" + } + } +} diff --git a/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json b/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json new file mode 100644 index 00000000000..d32be4b38d9 --- /dev/null +++ b/resources/plugins/launch/stablyai.orca-portuguese/orca-plugin.json @@ -0,0 +1,15 @@ +{ + "manifestVersion": 1, + "id": "orca-portuguese", + "publisher": "stablyai", + "name": "Português do Brasil", + "version": "1.0.0", + "description": "Brazilian Portuguese translations for common Orca navigation.", + "repository": "https://github.com/stablyai/orca-portuguese", + "engines": { "orca": ">=1.4.0" }, + "pluginApi": 1, + "contributes": { + "languagePacks": [{ "locale": "pt-BR", "path": "locales/pt-BR.json" }] + }, + "capabilities": [] +} diff --git a/skill-guides/orchestration.md b/skill-guides/orchestration.md index c7d15250e00..c1ac7d5d8f7 100644 --- a/skill-guides/orchestration.md +++ b/skill-guides/orchestration.md @@ -22,7 +22,7 @@ Use this skill when coordination state matters. For lightweight terminal prompts ## Tool Boundary -If a task says to use Orca orchestration, the coordinator must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject` or `orca orchestration run`. +If a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path. Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates. @@ -51,9 +51,35 @@ Do not use orchestration merely because the user says "hand off", "handoff", "ha - The orchestration experimental feature must be enabled in Settings > Experimental. - `orca orchestration` commands are RPC calls to the running Orca runtime. +## Contract Migration + +Orca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work. + +If a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`: + +1. Confirm `effectsApplied` is `false`. +2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`. +3. Read the guide completely. Do not retry the rejected command unchanged. +4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`. +5. Inspect any pre-upgrade terminal before creating replacement work. + +The arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. + +Pre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection: + +```bash +orca orchestration run-list --json +orca orchestration run-show --id run_legacy_local --json +orca orchestration task-list --run run_legacy_local --json +orca orchestration inbox --full --json +orca orchestration check --terminal --peek --json +``` + +Read-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run. + ## Ownership -Orchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane. +New orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run. Classify inherited context before sending lifecycle messages: @@ -78,36 +104,39 @@ orca orchestration dispatch-show --task --json ## Messaging ```bash -orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json] -orca orchestration check [--terminal ] [--unread|--peek|--all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json] +orca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json] +orca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json] orca orchestration reply --id --body [--from ] [--json] -orca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json] +orca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json] orca orchestration inbox [--limit ] [--json] ``` Rules: - Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal. -- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself. -- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only. -- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt. -- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting. +- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation. +- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch. +- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle. +- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles. +- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt. +- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting. - Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop. - Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet. -- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly. -- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion. +- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again. +- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles. - Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. -- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`. +- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`. - Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups. -- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals. +- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group. - A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides. -- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates. +- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates. ## Tasks And Dispatch -A task is the work item, a dispatch assigns it to a terminal, and a gate blocks progress until a coordinator or user decision is recorded. +A Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop. ```bash +orca orchestration run-create --objective --json orca orchestration task-create --spec [--deps ] [--parent ] [--json] orca orchestration task-list [--status ] [--ready] [--brief] [--json] orca orchestration task-update --id --status [--result ] [--json] @@ -124,19 +153,97 @@ Dispatch rules: - After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed. - Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag. -## Gates And Coordinator +## Preferred Supervised Worker Loop + +Use `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts. + +Create the Run and every independent Task first, then start all independent workers before waiting: + +```bash +orca orchestration run-create --objective "" --json +orca orchestration task-create --spec "" --json +orca orchestration task-create --spec "" --json +orca orchestration worker-start --task --worktree current --agent codex --json +orca orchestration worker-start --task --worktree current --agent claude --json +``` + +`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `. + +For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal: + +```bash +orca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json +# Independent/top-level: +orca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json +``` + +Setup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason. + +Read the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure. + +To run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`: + +```bash +# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home) +orca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json +orca orchestration worker-show --dispatch --json +orca orchestration worker-read --dispatch --limit 50 --json +orca orchestration send --to dispatch: --subject "Follow-up" --body "" --json +``` + +Remote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector. + +The follow-up is structured inbox mail, not prompt injection. The worker's next +`orchestration check` receives it even when the Dispatch is on another connected Orca server. + +`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: "terminal"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path. + +Wait until every expected Dispatch settles, not for a fixed number of batches: + +```bash +orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json +# Process every message in the returned Delivery, then atomically ack and continue: +orca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json +``` + +Workers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity: + +```bash +orca orchestration send --type worker_done --subject "" --body "" --task-id --dispatch-id --outcome succeeded --files-modified "path/a,path/b" --json +# On failure, use --outcome failed; never encode failure only in prose. +``` + +A worker question defaults to its owning Run. Timeout leaves it pending: + +```bash +orca orchestration ask --question "" --options "yes,no" --timeout-ms 600000 --json +orca orchestration ask --resume --timeout-ms 600000 --json +# Coordinator: +orca orchestration reply --id --body "" --json +``` + +Recovery is conditional, never a fixed destructive sequence: + +- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output. +- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement. +- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action. +- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes. + +Low-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express. + +## Gates And Legacy Inspection ```bash orca orchestration gate-create --task --question [--options ] [--json] orca orchestration gate-resolve --id --resolution [--json] orca orchestration gate-list [--task ] [--status ] [--json] -orca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json] -orca orchestration run-stop [--json] ``` -`run` returns immediately with a run ID. Query progress with `task-list`. Use `ask` for worker-to-coordinator questions; it creates a `decision_gate` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`. +Use `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`. -Recovery only: `orca orchestration reset --tasks|--messages|--all --json` clears runtime-global orchestration state. Do not run it during active coordination unless explicitly abandoning that state. +`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding. + +Recovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state. ## Full Handoffs @@ -151,7 +258,7 @@ Do not run `orca orchestration task-create`, `orca orchestration dispatch --inje New top-level worktree handoff: ```bash -orca worktree create --name --no-parent --agent codex --prompt "" --json +orca worktree create --name --no-parent --agent codex --prompt "" --setup run --json ``` Before creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`. @@ -166,12 +273,14 @@ Custom Codex model/effort handoff: `orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop. +The two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy. + Note: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Use the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree. ```bash -orca worktree create --name --no-parent --json +orca worktree create --name --no-parent --setup run --json orca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca terminal send --terminal --text "" --enter --json @@ -195,17 +304,19 @@ Reuse an idle agent in the required worktree only if the prompt allows reuse; ot When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base. +For every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees. + ```bash -orca worktree create --name --agent codex --json +orca worktree create --name --agent codex --setup run --json # or: --agent claude | omp | pi | grok | ... -# Read from startupTerminal.handle in the create response. +# Read from agentTerminalHandle, falling back to startupTerminal.handle. orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration dispatch --task --to --inject --json ``` -For new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. +For new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `. -**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. +**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree. Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble. @@ -228,11 +339,12 @@ Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding ## Agent Guidance -- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure: - `orca orchestration send --to --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --payload '{"taskId":"","dispatchId":"","filesModified":["path/a"],"reportPath":""}' --json` +- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`: + `orca orchestration send --type worker_done --subject "" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id --dispatch-id --outcome succeeded --files-modified "path/a" --report-path "" --json` +- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body. - After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input. - For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs: - `orca orchestration send --to --type heartbeat --subject "alive" --payload '{"taskId":"","dispatchId":"","phase":"implementing"}' --json` + `orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"","dispatchId":"","phase":"implementing"}' --json` - If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene. - Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop. - Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps. @@ -244,11 +356,11 @@ orca terminal create --worktree active --title login-css-worker --command "claud orca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json orca orchestration task-create --spec "Fix the login button CSS" --json orca orchestration dispatch --task --to --inject --json -orca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json +orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json ``` ## Next Action -Coordinator: confirm `orca status --json`, inspect `task-list`/`dispatch-show` if inheriting state, then choose either a manual loop (`task-create` -> worker -> `dispatch --inject` -> `check --wait`) or `orchestration run`. +Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. Worker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff. diff --git a/src/cli/browser-handler-groups.ts b/src/cli/browser-handler-groups.ts new file mode 100644 index 00000000000..0a7de2fa573 --- /dev/null +++ b/src/cli/browser-handler-groups.ts @@ -0,0 +1,119 @@ +import type { HandlerGroup } from './handler-group-manifest' + +// Why split out: the browser command surface is a third of the CLI's groups and +// changes as a unit, so it keeps handler-group-manifest.ts readable at a glance. +export const BROWSER_HANDLER_GROUPS: readonly HandlerGroup[] = [ + { + name: 'browser-nav', + keys: [ + 'snapshot', + 'screenshot', + 'goto', + 'back', + 'reload', + 'forward', + 'eval', + 'scroll', + 'wait', + 'pdf', + 'full-screenshot' + ], + load: async () => (await import('./handlers/browser-nav.js')).BROWSER_NAV_HANDLERS + }, + { + name: 'browser-interact', + keys: [ + 'click', + 'dblclick', + 'fill', + 'type', + 'select', + 'check', + 'uncheck', + 'focus', + 'clear', + 'select-all', + 'keypress', + 'hover', + 'drag', + 'upload', + 'scrollintoview', + 'get', + 'is', + 'inserttext', + 'mouse move', + 'mouse down', + 'mouse up', + 'mouse wheel', + 'find', + 'download', + 'highlight' + ], + load: async () => (await import('./handlers/browser-interact.js')).BROWSER_INTERACT_HANDLERS + }, + { + name: 'browser-tab', + keys: ['tab list', 'tab show', 'tab current', 'tab switch', 'tab create', 'tab close', 'exec'], + load: async () => (await import('./handlers/browser-tab.js')).BROWSER_TAB_HANDLERS + }, + { + name: 'browser-profile', + keys: [ + 'tab profile list', + 'tab profile create', + 'tab profile delete', + 'tab profile set', + 'tab profile show', + 'tab profile use-default', + 'tab profile clone' + ], + load: async () => (await import('./handlers/browser-profile.js')).BROWSER_PROFILE_HANDLERS + }, + { + name: 'browser-cookie', + keys: ['cookie get', 'cookie set', 'cookie delete'], + load: async () => (await import('./handlers/browser-cookie.js')).BROWSER_COOKIE_HANDLERS + }, + { + name: 'browser-capture', + keys: [ + 'intercept enable', + 'intercept disable', + 'intercept list', + 'capture start', + 'capture stop', + 'console', + 'network' + ], + load: async () => (await import('./handlers/browser-capture.js')).BROWSER_CAPTURE_HANDLERS + }, + { + name: 'browser-env', + keys: [ + 'viewport', + 'geolocation', + 'set device', + 'set offline', + 'set headers', + 'set credentials', + 'set media', + 'clipboard read', + 'clipboard write', + 'dialog accept', + 'dialog dismiss' + ], + load: async () => (await import('./handlers/browser-env.js')).BROWSER_ENV_HANDLERS + }, + { + name: 'browser-storage', + keys: [ + 'storage local get', + 'storage local set', + 'storage local clear', + 'storage session get', + 'storage session set', + 'storage session clear' + ], + load: async () => (await import('./handlers/browser-storage.js')).BROWSER_STORAGE_HANDLERS + } +] diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index 2400c7d3bcf..8b6e0d41847 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -30,7 +30,7 @@ const ORCA_LINEAR_MARKDOWN = "---\nname: orca-linear\ndescription: >-\n Use Orc const ORCA_PER_WORKSPACE_ENV_MARKDOWN = "---\nname: orca-per-workspace-env\ndescription: >-\n Set up, review, debug, or validate Orca per-workspace environment recipes —\n on-demand, disposable runtimes (cloud sandboxes, VMs, or local) created fresh\n for each workspace. Covers first-time setup (provider prerequisites, the\n reusable base snapshot, the coding-agent auth snapshot, credentials, and\n state), not just the per-workspace lifecycle scripts. Use to stand up\n per-workspace environments, fix an `environmentRecipes` entry in `orca.yaml`, scaffold\n provider lifecycle scripts, or resolve an `orca vm recipe doctor` failure.\n---\n\n# Per-Workspace Environments\n\nHelp a user stand up and maintain a repo-owned per-workspace environment recipe end to end. Each\nworkspace gets its own on-demand, disposable runtime (a cloud sandbox, a VM, or a local one),\ncreated fresh and torn down after.\n\nOrca is a **thin wrapper**: you guide, detect, and scaffold; you never own the user's cloud account,\nbilling, images, or credentials.\n\n- **You DO:** sequence the setup, detect what's detectable (provider CLI present/logged-in? recipe\n present? `doctor` passing?), scaffold provider-templated scripts the user fills in, drive the slow\n snapshot/auth phases with the user, and always show the next action.\n- **You DO NOT:** create accounts, choose plans/regions, invent org/project/scope ids, store or print\n secrets, or run anything that spends money without an explicit user OK.\n\nFirst-time setup has **four phases before the per-workspace recipe runs** — easy to miss, so walk\nthem in order:\n\n1. **Prerequisites** — cloud account, provider CLI, scope/project, plan limits, git token (§2).\n2. **Base snapshot** — reusable image: tools + repo + headless build, snapshotted once (§3).\n3. **Agent-auth snapshot** — boot the base, run interactive device-auth, re-snapshot (§4).\n4. **State** — thread snapshot id / scope / project / port between phases via a state file (§6).\n\nThen the **per-workspace contract** (create/suspend/resume/destroy) runs fast (§8).\n\n**The one branch that shapes everything — connection mode:** **Orca-server** (`create` runs `orca serve`\nin the env and emits a `pairingCode`; §7c/§7f) vs **SSH** (`create` runs no server and emits a\n`connection.type:\"ssh\"` block Orca dials into; §7g/§7h). Settle this first — it changes the `create`\noutput shape and half the templates.\n\n**Quick-start (happy path):** interview the user (connection mode Orca-server vs SSH, provider, agent CLI,\ngit auth — §1.2) + read the provider's CLI docs → scaffold `scripts/orca-vm/` from §7 → run the\nbase-snapshot script, then the auth script (you invoke these by hand; not via `orca.yaml`) → wire\n`environmentRecipes` in `orca.yaml` → `orca vm recipe doctor --json` (free) → then the `--provision`\nself-test loop (§9) until it passes.\n\n---\n\n## 1. Setup workflow\n\nDrive these with the user. **[CHECKPOINT]** steps need explicit confirmation — they spend money, take\na long time, or need the user at the keyboard. Never create an Orca workspace or commit unless asked.\n\n1. **Inspect the repo** for an existing `environmentRecipes` entry, `scripts/orca-vm/`, a state file, or setup\n notes. If a working recipe exists, jump to Doctor (§9) instead of rebuilding.\n2. **Interview the user up front** — gather these choices and confirm them back before scaffolding\n anything. Don't pick for them (§11); don't guess.\n - **Connection mode:** how Orca attaches to the environment — an **Orca server** (the VM runs\n `orca serve` and Orca pairs over its pairing URL; worked example §7f) or **SSH** (Orca connects to\n the host over SSH; §7g). This decides the recipe's connection shape, so settle it first.\n - **Provider:** Vercel Sandbox, Fly, Modal, an existing SSH host, … For non-obvious providers, also\n ask scope/project/region and plan limits (§2). Then **read that provider's CLI/SDK docs** (or\n ` --help`) before scaffolding — you need its exact create/exec/snapshot/remove verbs.\n If a provider advertises `ssh`, verify whether it exposes a real dialable SSH target\n (host/port/user/key or proxy command) or only a provider-mediated interactive shell; Orca SSH mode\n needs the former.\n - **Coding-agent CLI + account:** which agent runs in the VM (`codex`, `claude`, …) and that the user\n has an account for it — it gets logged in during the Phase-3 auth snapshot (§4).\n - **Git auth:** the token source for cloning a private repo (`GH_TOKEN`/`GITHUB_TOKEN` or `gh auth\n token`; §5).\n3. **Check prerequisites (§2)** — detect the provider CLI + auth and confirm the items above are in\n place before any paid step.\n4. **Scaffold scripts + state file** from §7 (worked Vercel example: §7f; SSH host: §7g; Docker SSH:\n §7h; Windows: §7i), filling in the provider's real commands. Make them executable.\n5. **[CHECKPOINT] Build the base snapshot (§3)** — paid, slow.\n6. **[CHECKPOINT] Authenticate the agent (§4)** — interactive; the user follows a URL/code. **You cannot\n drive this step** — you run commands non-interactively, so there's no TTY for `docker exec -it` /\n `ssh -t` to prompt against. The **user** runs the Phase-3 login in their own terminal (or via the\n Claude Code harness bang-prefix — `! `, with the required space after `!`); you scaffold and drive\n the non-interactive phases around it. After kicking it off, **ask the user to report back once the login\n finishes** — you can't observe it completing, and you need that confirmation before resuming the\n non-interactive steps (base/auth commit, doctor, provision).\n7. **Wire the recipe** so `orca.yaml` points create/suspend/resume/destroy at the scripts (§8). The\n workspace composer reads `environmentRecipes` from the project's primary checkout of `orca.yaml`, **not** from\n a feature branch or worktree. So a recipe added only on a branch won't appear as a \"Run on\" option\n until that `orca.yaml` change is committed and merged to the project's primary branch. Tell the user\n this up front: `doctor`/`--provision` validate the scripts from the working copy on any branch, but\n creating a workspace from the recipe in the picker needs it on primary.\n8. **Dry-run doctor** — `orca vm recipe doctor --repo-path --json` (free, static; §9).\n Fix every failure before going live.\n9. **[CHECKPOINT] Live self-test** — get the user's OK once, then run\n `orca vm recipe doctor --provision --json` as a loop: it runs create → validates →\n destroys, and on failure returns a full transcript. Read it, fix the scripts, and re-run yourself until\n it passes (§9). Spends cloud money; the one approval covers the loop.\n10. **[CHECKPOINT] Optional workspace test** — only if asked: create a workspace via the picker, then\n verify sleep/wake/delete.\n\n---\n\n## 2. Phase 1 — Prerequisites\n\nThe user's responsibility; verify what's verifiable, ask for the rest, invent nothing. State which\nitems you verified vs. which the user asserted.\n\n- **Connection mode** (Orca server vs SSH) confirmed with the user — see §1 step 2; it shapes the recipe.\n- **Cloud account + plan** that allows sandboxes/VMs. Ask.\n- **Provider CLI installed + authenticated** — detect (`command -v `), check auth (e.g.\n `vercel whoami`). If missing, point at the provider's docs; don't log them in.\n- **Scope / project / region** the sandboxes live under. Ask; flows into every script via state.\n- **Plan / timeout / RAM caps.** Record them — e.g. Vercel Hobby caps sandbox timeout at **45m**,\n which limits both the base build and per-workspace runtime (see §10).\n- **Git token for private repos** (`GH_TOKEN`/`GITHUB_TOKEN`, or the provider's git auth; can fall back\n to `gh auth token`). See §5.\n- **Coding-agent CLI choice** (`codex`, `claude`…) and that the user has an account — it gets\n authenticated into the VM in Phase 3.\n\n---\n\n## 3. Phase 2 — Base snapshot (the reusable image)\n\nBuild **once**, snapshot, and every workspace boots from it in seconds instead of rebuilding.\nProvisioning + building takes a while (often ~20–30 min), so it runs behind a checkpoint. The script\nshape is §7a; key points:\n\n- Build the **headless Electron main only** (not the renderer) so it fits in plan RAM.\n- Use the VM image's package manager (`apt`/`dnf`/`apk`, per the base distro — not the provider brand).\n- Clone with the git token via `GIT_ASKPASS` (§5).\n- **Trap errors and remove the half-built sandbox** so a crash doesn't leave a paid resource running.\n- Snapshot the stopped sandbox, parse the snapshot id, and write it + scope/project/port/repo to state.\n\n---\n\n## 4. Phase 3 — Agent-auth snapshot (interactive)\n\nThe base snapshot has the agent CLI installed but **not logged in**, and per-workspace VMs are\nephemeral — so authenticate once and bake it into a second snapshot layer. Script shape is §7b:\n\n1. Boot a sandbox from the base `snapshotId` (from state).\n2. Run the agent's login **interactively** (`--interactive --tty`); the user completes the URL/code in\n their browser. On a **headless VM this must be the device-auth flow** (e.g. `codex login --device-auth`),\n **not** plain `codex login`: the default OAuth login starts a loopback callback server on a container\n port the host browser can't reach, so it hangs. Device-auth instead prints a URL + code the user opens\n on the **host**.\n3. Verify login; **refuse to snapshot an unauthenticated VM.** Prefer the status command's **exit code**\n (most agent CLIs exit non-zero when unauthenticated). If you grep instead, agent status often goes to\n **stderr** (e.g. `codex login status` prints \"Logged in using ChatGPT\" there), so **fold stderr first**\n (`... 2>&1 | grep …`) and match the agent's **exact success line** — never `grep -qi 'logged in'`, which\n also matches \"**not** logged in\" and would commit an unauthenticated image.\n4. Re-snapshot, parse the new id, and overwrite `snapshotId` in state to the authenticated image\n (recording `authSourceSnapshotId`). Remove the auth sandbox.\n\n**You can't drive step 2 yourself** (you run commands non-interactively — no TTY). The **user** runs it in\ntheir own terminal, or via the Claude Code harness bang-prefix (`! `, with the required space after\n`!`). You scaffold/boot the sandbox and run steps 3–4, but **you cannot observe the interactive login\nfinishing** — so **ask the user to tell you when it's done** before you verify and re-snapshot.\n\nIf the agent's credentials are short-lived, warn that the snapshot may need periodic re-auth (§10).\n\nFor disposable runtimes, do **not** treat a host agent config directory (for example `~/.codex`) as the\nauth snapshot by bind-mounting or copying it wholesale. Agent homes often contain sqlite state, hook\napproval state, caches, logs, and host-specific env/config. Instead, authenticate/configure the agent\ninside the disposable runtime and snapshot/commit that runtime layer.\n\n---\n\n## 5. Credentials\n\n- **Never** commit secrets or put them in `userData`, recipe JSON, comments, docs, or the state file.\n- **Git token:** read from env (`GH_TOKEN`/`GITHUB_TOKEN`), falling back to `gh auth token`. Pass to the\n VM only via the provider's ephemeral `--env`. Inside the VM, use a `GIT_ASKPASS` helper with\n `x-access-token` (not the token in the clone URL) and `GIT_TERMINAL_PROMPT=0` so a missing token fails\n fast instead of hanging. When you write the helper from inside `bash -lc` under `set -u`, escape the\n positional arg and the token (`\\$1`, `\\$GH_TOKEN`) so they land **literally** and resolve at git-runtime\n — an unescaped `$1` aborts with \"unbound variable\", and a literal `$GH_TOKEN` keeps the real token out of\n the written file. `rm -f` the helper after the clone/fetch.\n- **Provider auth:** rely on the provider CLI's logged-in session, not checked-in keys.\n- **Agent auth:** lives in the authenticated snapshot (Phase 3) — never a file you write or commit.\n- State holds only **non-secret** wiring (snapshot ids, scope, project, port, repo url/ref).\n\n---\n\n## 6. State file\n\nA repo-local JSON file (e.g. `scripts/orca-vm/-state.json`) threads non-secret values between\nphases. Each script resolves values as **env var → state → built-in fallback**, and merges its outputs\nback. Phase 2 writes the base `snapshotId`; Phase 3 overwrites it with the authenticated snapshot;\nper-workspace `create` boots from `snapshotId`.\n\n```json\n{\n \"baseName\": \"orca-base\",\n \"snapshotId\": \"snap_authenticated_image_id\",\n \"authSourceSnapshotId\": \"snap_base_image_id\",\n \"scope\": \"\",\n \"project\": \"\",\n \"port\": 7331,\n \"repoUrl\": \"https://host/org/repo.git\",\n \"repoRef\": \"main\",\n \"projectRoot\": \"/abs/path/on/remote/repo\"\n}\n```\n\n---\n\n## 7. Script templates (provider-agnostic shapes)\n\nScaffold under `scripts/orca-vm/`. These are **shapes** — fill in the provider's real commands. All\nreserve stdout for the final JSON and log progress to stderr. Include a shared `json_value ` /\n`env_value ` reader (env → state → fallback) in each.\n\n**Where each script runs:**\n\n- **Local-side** (`create`/`suspend`/`resume`/`destroy` + the base-snapshot/auth scripts the user\n invokes) runs **on the user's desktop**, so it must run on their OS. macOS/Linux: `#!/usr/bin/env\n bash`, `set -euo pipefail`, quoted paths. **Windows:** a bare `.sh` won't run — scaffold `.ps1`/`.cmd`\n or require WSL/Git-Bash and point `orca.yaml` at the right launcher.\n- **Remote-side** (commands you `exec` *inside* the Linux VM) always runs in the VM's Linux shell, so\n bash is fine there regardless of the user's OS.\n\n### 7a. Base-snapshot (`-base-snapshot.sh`) — Phase 2\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve base_name/repo_url/repo_ref/project_root/port/scope/project/timeout (env→state→fallback)\n# resolve gh token: GH_TOKEN | GITHUB_TOKEN | `gh auth token`\n# 1. provision a sandbox (timeout/vcpus/published port/snapshot retention); trap: remove on error\n# 2. remote exec (long timeout): install pkgs + gh + corepack/pnpm + agent CLI;\n# clone with GIT_ASKPASS(token); write headless main-only build config;\n# dev setup; pnpm install; build CLI; build headless electron main; smoke-check tools\n# 3. snapshot stopped sandbox; parse snapshot id (fail if unparseable)\n# 4. merge { baseName, snapshotId, projectRoot, repoUrl, repoRef, port, scope, project } into state\n# print only the state JSON to stdout\n```\n\nWorked Vercel commands for this phase are in §7f. You run this script by hand (not via `orca.yaml`),\nafter exporting the first-run inputs the state file doesn't have yet — e.g. provider scope/project, the\nrepo URL/ref, and a git token (`GH_TOKEN`); later runs read them back from state.\n\n### 7b. Auth (`-base-auth.sh`) — Phase 3\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read source snapshot from state.snapshotId (fail if absent); auth_name=\"${base_name}-auth\"\n# 1. boot sandbox from source snapshot; trap: remove on error\n# 2. INTERACTIVE/TTY remote exec: agent login — user completes URL/code. Headless VM: MUST use the\n# device-auth flow (e.g. `codex login --device-auth`) — plain OAuth login binds a loopback callback\n# port the host can't reach and hangs. User runs this themselves (you have no interactive TTY); ask\n# them to report back when it's done before continuing.\n# 3. verify login, then refuse to snapshot if not logged in. Prefer the status command's EXIT CODE (most\n# agent CLIs exit non-zero when unauthenticated) over string-matching. If you must grep, fold stderr\n# first (`status 2>&1 | grep …` — many agents print the success line there) and match the agent's exact\n# success line; never `grep -qi 'logged in'`, which also matches \"not logged in\". Codex example: §7f.\n# 4. snapshot; parse new id\n# 5. merge { snapshotId:, authSourceSnapshotId: } into state; remove auth sandbox\n# print only the state JSON to stdout\n```\n\n### 7c. Create (`-create.sh`) — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# read authenticated snapshotId/scope/project/port/repo*/project_root (env→state→fallback)\n# fail clearly if snapshotId is missing (point back to Phases 2–3)\n# name = orca-${ORCA_VM_RECIPE_ID}-${ORCA_VM_INSTANCE_ID} (sanitized, length-capped)\n# 1. boot sandbox from snapshotId with a published port; capture the public URL → pairing address\n# (an externally reachable wss:// URL); trap: remove sandbox on error\n# 2. remote exec: ensure repo at desired commit; rebuild only if commit changed (cache marker)\n# 3. remote exec: start orca serve in the background and read the recipe JSON it writes (see below)\n# 4. print serve's JSON to stdout, optionally enriched with userData:\n# { schemaVersion:1, pairingCode, projectRoot, userData:{ provider, resourceId:name, snapshotId } }\n```\n\n**The exact `orca serve` invocation and its output (verified — do not improvise the flags).** Inside the\nVM, run:\n\n```bash\norca serve \\\n --port \"$PORT\" \\\n --project-root \"$ABS_REPO_PATH_ON_REMOTE\" \\\n --pairing-address \"$EXTERNAL_WSS_URL\" \\\n --recipe-json\n```\n\n**Binary name:** in a VM built from source (the Phase-2 flow), run it as `pnpm exec orca-dev serve …`\nfrom the repo root — `orca-dev` is the in-repo entrypoint and is what the §7f example uses. Plain\n`orca serve …` is the same command when the built CLI is installed on the VM's PATH. The flags/output\nare identical either way.\n\nThere is **no `--host` flag**. `--project-root` must be an absolute directory on the remote. With\n`--recipe-json` the server **stays running** and prints exactly this single object to **stdout**, then\nkeeps serving:\n\n```json\n{ \"schemaVersion\": 1, \"pairingCode\": \"\", \"projectRoot\": \"\" }\n```\n\n`pairingCode` is the pairing URL, already pointing at whatever you passed as `--pairing-address` — so set\n`--pairing-address` to the externally reachable address and **pass `pairingCode` through unchanged; never\nhand-rewrite it**. Because serve runs in the foreground and doesn't exit, redirect its stdout to a file\nand poll until that file parses as JSON (and bail if the process dies — dump its stderr log). Your\n`create` script then prints that JSON (optionally merging `userData`). Concrete pattern: §7f.\n\n### 7d. Suspend / resume / destroy — per workspace\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\npayload=\"$(cat)\" # Orca passes lifecycle JSON on stdin\nresource_id=\"$(node -e 'const d=JSON.parse(process.argv[1]); process.stdout.write(d.recipeResult?.userData?.resourceId ?? \"\")' \"$payload\")\"\n[ -n \"$resource_id\" ] || { echo \"No resource id in lifecycle payload\" >&2; exit 1; }\n# suspend: provider suspend \"$resource_id\"\n# resume: provider resume \"$resource_id\"; then RE-EMIT fresh recipe JSON (pairing may change)\n# destroy: provider remove \"$resource_id\" (or set destroy: none in orca.yaml)\n```\n\n### 7e. State file — scaffold with scope/project/repo filled in and snapshot ids empty (§6).\n\n### 7f. Worked example — Vercel Sandbox (all three phases)\n\nA real, working shape (the Vercel surface is a CLI: `vercel sandbox create|exec|snapshot|remove`). Adapt\nnames; verify flags against `vercel sandbox --help` for the user's CLI version before relying on them.\nThese ground §7a (base snapshot) and §7b (auth), which are otherwise generic skeletons.\n\n**Phase 2 — base snapshot (§7a):** provision → install tools + clone + headless build → snapshot.\n\n```bash\n# provision a fresh build sandbox (retain a couple of snapshots); trap-remove on error\nvercel sandbox create --name \"$base\" --runtime node24 --timeout 30m --vcpus 4 --publish-port \"$port\" \\\n --snapshot-expiration 30d --keep-last-snapshots 2 \"${vercel_args[@]}\" >&2\n# remote build (long timeout): install pkgs+gh+pnpm+agent CLI, clone with GIT_ASKPASS (write the helper\n# with LITERAL \\$1/\\$GH_TOKEN so they resolve at git-runtime, not write-time — see §5/§7f create — then\n# `rm -f /tmp/askpass.sh`), write the headless main-only build config (drop the renderer), dev setup,\n# build CLI + headless main, smoke-check\nvercel sandbox exec \"$base\" \"${vercel_args[@]}\" --timeout 25m --env \"GH_TOKEN=$gh_token\" … -- bash -lc '…build…' >&2\n# snapshot the STOPPED sandbox and parse the id from CLI output (fail if unparseable)\nout=\"$(vercel sandbox snapshot \"$base\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nsnapshot_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# merge { baseName, snapshotId, scope, project, port, repoUrl, repoRef, projectRoot } into state; print state JSON\n```\n\n**Phase 3 — agent-auth snapshot (§7b):** boot the base, log the agent in interactively, re-snapshot.\n(`codex` below is an example — substitute the user's chosen agent's login/status verbs, e.g. `claude`.)\n\n```bash\nvercel sandbox create --name \"$auth\" --snapshot \"$snapshot_id\" --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" >&2\n# INTERACTIVE — the USER runs this in their own terminal (you have no interactive TTY) and completes the\n# URL/code on the HOST. --device-auth is MANDATORY on a headless VM: plain `codex login` binds a loopback\n# callback port the host browser can't reach and hangs. Ask the user to report back when login finishes.\nvercel sandbox exec --interactive --tty \"$auth\" \"${vercel_args[@]}\" -- bash -lc 'codex login --device-auth'\n# refuse to snapshot an unauthenticated VM — fold stderr, match codex's exact success line (§4)\nvercel sandbox exec \"$auth\" \"${vercel_args[@]}\" --timeout 30s -- bash -lc 'codex login status 2>&1' | grep -Eqi 'Logged in using ChatGPT|Logged in via device' \\\n || { echo \"agent not logged in; not snapshotting\" >&2; exit 1; }\nout=\"$(vercel sandbox snapshot \"$auth\" --stop --expiration 30d \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$out\" >&2\nnew_id=\"$(printf '%s\\n' \"$out\" | sed -nE 's/.*(snap_[A-Za-z0-9]+).*/\\1/p' | tail -1)\"\n# overwrite state.snapshotId = new_id, record authSourceSnapshotId = snapshot_id; remove the auth sandbox\n```\n\n**Per-workspace `create`** (the fast path):\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback: snapshot_id, scope, project, port, repo_url, repo_ref, project_root\nvercel_args=(); [ -n \"$scope\" ] && vercel_args+=(--scope \"$scope\"); [ -n \"$project\" ] && vercel_args+=(--project \"$project\")\n[ -n \"$snapshot_id\" ] || { echo \"snapshotId missing — run Phases 2–3 first\" >&2; exit 1; }\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nname=\"orca-${ORCA_VM_RECIPE_ID:-vercel-sandbox}-${ORCA_VM_INSTANCE_ID:-$(date +%s)}\" # sanitize+cap to 63 chars\n\n# Arm cleanup BEFORE create so a failing create can't leak a half-built paid sandbox.\ncleanup_on_error() { [ \"$?\" -ne 0 ] && vercel sandbox remove \"$name\" \"${vercel_args[@]}\" >/dev/null 2>&1 || true; }\ntrap cleanup_on_error EXIT\n\n# 1. boot from the authenticated snapshot, publish the serve port\ncreate_output=\"$(vercel sandbox create --name \"$name\" --snapshot \"$snapshot_id\" \\\n --timeout 30m --publish-port \"$port\" \"${vercel_args[@]}\" 2>&1)\"; printf '%s\\n' \"$create_output\" >&2\n# Vercel prints the published https URL; derive the external wss:// pairing address from it\npublic_url=\"$(printf '%s\\n' \"$create_output\" | sed -nE 's#.*(https://[^[:space:]]+\\.vercel\\.run).*#\\1#p' | head -1)\"\n[ -n \"$public_url\" ] || { echo \"no published URL in create output\" >&2; exit 1; }\npairing_ws=\"${public_url/https:\\/\\//wss://}\"\n\n# 2. (remote) ensure the repo is at the right commit; rebuild only if the commit changed (cache marker)\nvercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 20m \\\n --env \"GH_TOKEN=$gh_token\" --env \"ORCA_PROJECT_ROOT=$project_root\" \\\n --env \"ORCA_REPO_URL=$repo_url\" --env \"ORCA_REPO_REF=$repo_ref\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; \\\n # Re-establish git auth for the private-repo fetch (why + full rationale: §5); else it hangs on a prompt.\n # Load-bearing escaping: \\$1 and \\$GH_TOKEN must land LITERALLY and resolve at git-runtime. Test after\n # any edit here — reformatting the nested printf/node quoting silently breaks the fetch or leaks the token.\n if [ -n \"${GH_TOKEN:-}\" ]; then \\\n printf \"%s\\n\" \"#!/usr/bin/env bash\" \"case \\\"\\$1\\\" in *Username*) echo x-access-token;; *Password*) echo \\\"\\$GH_TOKEN\\\";; esac\" > /tmp/askpass.sh; \\\n chmod 700 /tmp/askpass.sh; export GIT_ASKPASS=/tmp/askpass.sh GIT_TERMINAL_PROMPT=0; fi; \\\n git fetch origin \"$ORCA_REPO_REF\"; \\\n git checkout -B \"$ORCA_REPO_REF\" FETCH_HEAD; \\\n rm -f /tmp/askpass.sh; \\\n c=\"$(git rev-parse HEAD)\"; [ -f .orca-built ] && [ \"$(cat .orca-built)\" = \"$c\" ] || { \\\n pnpm install --prefer-offline && pnpm run build:cli && \\\n node config/scripts/run-electron-vite-build.mjs --config config/electron-vite.vm-serve.config.ts && \\\n printf \"%s\" \"$c\" > .orca-built; }' >&2\n\n# 3. (remote) start orca serve in the background, writing recipe JSON to a file; poll until it parses\nrecipe_json=\"$(vercel sandbox exec \"$name\" \"${vercel_args[@]}\" --timeout 60s \\\n --env \"ORCA_PORT=$port\" --env \"ORCA_PROJECT_ROOT=$project_root\" --env \"ORCA_PAIRING_ADDRESS=$pairing_ws\" \\\n -- bash -lc 'set -euo pipefail; cd \"$ORCA_PROJECT_ROOT\"; rm -f /tmp/orca-recipe.json /tmp/orca-serve.log; \\\n nohup pnpm exec orca-dev serve --port \"$ORCA_PORT\" --project-root \"$ORCA_PROJECT_ROOT\" \\\n --pairing-address \"$ORCA_PAIRING_ADDRESS\" --recipe-json >/tmp/orca-recipe.json 2>/tmp/orca-serve.log /dev/null 2>&1 && { cat /tmp/orca-recipe.json; exit 0; }; \\\n kill -0 \"$pid\" 2>/dev/null || { cat /tmp/orca-serve.log >&2; exit 1; }; sleep 0.25; \\\n done; cat /tmp/orca-serve.log >&2; echo \"serve recipe JSON timed out\" >&2; exit 1')\"\n\n# 4. print serve's JSON enriched with userData (single object on stdout)\nnode -e 'const p=JSON.parse(process.argv[1]); console.log(JSON.stringify({...p, schemaVersion:1,\n userData:{...p.userData, provider:\"vercel-sandbox\", resourceId:process.argv[2], snapshotId:process.argv[3]}}))' \\\n \"$recipe_json\" \"$name\" \"$snapshot_id\"\ntrap - EXIT\n```\n\n`suspend`/`resume`/`destroy` use `vercel sandbox stop|...|remove \"$resource_id\"` reading\n`userData.resourceId` from stdin (§7d). This is the **Orca-server** connection mode (the recipe emits a\npairing URL). If the user chose **SSH** in the §1 interview, use §7g instead.\n\n### 7g. Worked example — existing SSH host (SSH connection mode)\n\nSSH mode is **fundamentally different from §7c/§7f**, not a relabeling of them:\n\n- **`create` does NOT run `orca serve` and does NOT emit a `pairingCode`.** Orca itself connects to the\n host over its SSH relay, brings up the git + filesystem providers, and imports the repo. The script's\n only job is to make the host ready and **print SSH connection details** Orca will dial.\n- The result uses a `connection` block with `type: \"ssh\"` and a `target`, **not** the flat\n `pairingCode`/`projectRoot` shape. Exact shape (Orca rejects anything else):\n\n```json\n{\n \"schemaVersion\": 1,\n \"connection\": {\n \"type\": \"ssh\",\n \"projectRoot\": \"/abs/path/to/repo/on/host\",\n \"target\": {\n \"label\": \"my-box\",\n \"host\": \"192.0.2.10\",\n \"port\": 22,\n \"username\": \"ubuntu\",\n \"identityFile\": \"~/.ssh/id_ed25519\",\n \"jumpHost\": \"bastion.example.com\",\n \"proxyCommand\": \"cloudflared access ssh --hostname %h\",\n \"relayGracePeriodSeconds\": 0,\n \"portForwards\": []\n }\n }\n}\n```\n\n`label`, `host`, `port`, `username` are required; the rest are optional — omit any you don't need.\n\n**Networking → which `target` fields to set** (how *your desktop* reaches the box — there is no\n`orca serve` URL in SSH mode):\n\n- Public IP / DNS, or a Tailscale/VPN address → `host`; SSH port → `port` (usually 22).\n- Key auth → `identityFile` (add `identitiesOnly: true` if the agent has many keys).\n- Through a bastion → `jumpHost` (a `user@host` ProxyJump) **or** a full `proxyCommand` (e.g. an access\n proxy). Use one, not both.\n- A service port the workspace needs → add entries to `portForwards`.\n- `relayGracePeriodSeconds` (optional): how long Orca keeps the SSH relay alive after the workspace\n detaches before tearing it down; `0` = tear down immediately. Leave it off unless the user wants a\n reconnect grace window.\n\n**Toolchain & agent auth on a persistent (no-snapshot) host — do this ONCE, by hand, before wiring the\nrecipe** (there's no base image to bake; the host *is* the base). Run the §7f Phase-2 install steps and\nthe §7f Phase-3 ` login --device-auth` **directly over SSH on the host** (interactive, e.g.\n`ssh -t user@host ' login --device-auth'`). After that the host stays ready across workspaces.\n\n```bash\n#!/usr/bin/env bash\nset -euo pipefail\n# resolve from env→state→fallback (default unset optionals to \"\"): ssh_username, host,\n# ssh_port (default 22), identity_file, jump_host, proxy_command, project_root, repo_url, repo_ref\n: \"${identity_file:=}\"; : \"${jump_host:=}\"; : \"${proxy_command:=}\" # avoid set -u aborts on optionals\ngh_token=\"${GH_TOKEN:-${GITHUB_TOKEN:-$(command -v gh >/dev/null 2>&1 && gh auth token 2>/dev/null || true)}}\"\nssh_target=\"${ssh_username}@${host}\"\nssh_opts=(-p \"$ssh_port\"); [ -n \"$identity_file\" ] && ssh_opts+=(-i \"$identity_file\")\n# Why: a fresh host's key isn't in known_hosts; a StrictHostKeyChecking prompt would HANG a\n# non-interactive create. Pre-add the key (or set the option) so it can't block.\nssh-keyscan -p \"$ssh_port\" \"$host\" >> \"$HOME/.ssh/known_hosts\" 2>/dev/null || true\n\n# 1. ensure the repo is present and at the right commit on the host (NO orca serve here)\nssh \"${ssh_opts[@]}\" \"$ssh_target\" \\\n \"GH_TOKEN='$gh_token' GIT_TERMINAL_PROMPT=0 bash -lc '\n set -euo pipefail\n [ -d \\\"$project_root/.git\\\" ] || git clone \\\"$repo_url\\\" \\\"$project_root\\\"\n cd \\\"$project_root\\\" && git fetch origin \\\"$repo_ref\\\" && git checkout -B \\\"$repo_ref\\\" FETCH_HEAD\n '\" >&2\n\n# 2. print the SSH connection block (NO pairingCode, NO orca serve). host/port/username tell Orca's\n# relay how to dial in; identityFile/jumpHost/proxyCommand/portForwards are emitted when set.\nnode -e 'const [host,port,user,idf,jh,pc,root]=process.argv.slice(1);\n const target={ label:\"per-workspace-host\", host, port:Number(port), username:user };\n if(idf) target.identityFile=idf; if(jh) target.jumpHost=jh; if(pc) target.proxyCommand=pc;\n // add target.portForwards=[...] here if the workspace needs forwarded service ports\n console.log(JSON.stringify({ schemaVersion:1, connection:{ type:\"ssh\", projectRoot:root, target } }))' \\\n \"$host\" \"$ssh_port\" \"$ssh_username\" \"$identity_file\" \"$jump_host\" \"$proxy_command\" \"$project_root\"\n```\n\n`suspend`/`resume`/`destroy`: on a persistent host there's usually nothing to tear down — set\n`destroy: none` and omit suspend/resume. (Orca still disconnects/reconnects its own SSH relay on\nsleep/wake/delete — that's separate from these scripts.)\n\nIf the SSH host is instead an **ephemeral/snapshot-capable VM** (your hypervisor, or a cloud VM with\nimage support), keep the §7f Phase-2/3 base-image model for provisioning, but still emit the\n`connection.type:\"ssh\"` block above instead of starting `orca serve`.\n\n### 7h. Worked example — local Docker SSH (SSH connection mode)\n\nLocal Docker can model an ephemeral SSH VM without cloud cost: build a base image with `sshd`, tools,\nrepo prerequisites, and the agent CLI; run an **interactive auth container** once; then `docker commit`\nthat container as the authenticated image used by per-workspace `create`.\n\nKey points:\n\n- Publish container SSH to a random localhost port (`-p 127.0.0.1::22`) and emit\n `connection.type:\"ssh\"` with `host:\"127.0.0.1\"`, that port, `username`, `identityFile`, and\n `identitiesOnly:true`.\n- Generate a repo-local SSH key if needed, but gitignore the private/public key files.\n- **Bake SSH host keys into the base image** (`ssh-keygen -A` at **build** time; at runtime only generate\n if absent). Ephemeral containers all present the **same** host key, so `known_hosts` on `127.0.0.1`\n doesn't churn as the published port rotates across workspaces (otherwise every container's freshly\n generated key collides on `localhost` and trips host-key-changed warnings).\n- The auth image is the Docker equivalent of Phase 3: the **user** runs the agent login **inside** the\n container (you can't drive it — you have no interactive TTY), configures proxy env/config, approves\n hooks, and you commit once they report it's done. On a headless container use the **device-auth** flow\n (§4). Verify login before committing — exit code, or fold stderr and match the exact success line (§4).\n- Do not bind-mount or copy the host's full agent home into the image. Let each container have writable\n agent state; only the committed auth image should carry reusable authenticated state.\n- If committing from an interactive shell, force the runtime entrypoint back to `sshd`:\n `docker commit --change='ENTRYPOINT [\"/usr/local/bin/orca-docker-ssh-entrypoint\"]' …`.\n- `destroy` should read `recipeResult.userData.resourceId` and run `docker rm -f \"$resource_id\"`.\n\nValidation before wiring/live use:\n\n```bash\ndocker image inspect \"$auth_image\" --format '{{json .Config.Entrypoint}}'\ndocker run -d --name \"$name\" -p 127.0.0.1::22 -e \"ORCA_SSH_PUBLIC_KEY=$pubkey\" \"$auth_image\"\ndocker ps -a --filter \"name=$name\"\ndocker logs \"$name\"\nssh -i \"$key\" -p \"$port\" -o IdentitiesOnly=yes user@127.0.0.1 'codex --version'\n```\n\nIf the container exits immediately, inspect logs before the cleanup trap removes it; a committed\ninteractive image with `ENTRYPOINT [\"bash\"]` is a common cause.\n\nAlso confirm the **host key is stable** across containers: the SSH `ssh -i … 127.0.0.1` dial should not\ntrigger a host-key-changed warning when a second container reuses the port. If it does, the host keys\nweren't baked into the base image (see the `ssh-keygen -A` point above).\n\n### 7i. Windows local-side scripts\n\nThe local-side scripts run on the user's desktop. On **Windows**, a bare `.sh` won't execute. Either\nrequire WSL/Git-Bash (and point `orca.yaml` at e.g. `bash ./scripts/orca-vm/.sh` via a `.cmd`\nlauncher), or scaffold PowerShell equivalents. Minimal PowerShell shape:\n\n```powershell\n#requires -Version 5\n$ErrorActionPreference = 'Stop'\n# resolve env→state→fallback; run the provider CLI / ssh the same way;\n# capture provider output; build the result object for the chosen mode and write ONE line of JSON to stdout.\n# Orca-server mode: @{ schemaVersion=1; pairingCode=$pairingCode; projectRoot=$projectRoot; userData=@{...} }\n# SSH mode: @{ schemaVersion=1; connection=@{ type=\"ssh\"; projectRoot=$projectRoot;\n# target=@{ label=$label; host=$host; port=$port; username=$user } } } (see §7g/§7h)\n($result | ConvertTo-Json -Compress -Depth 6)\n# progress/errors → Write-Error / the error stream, never stdout.\n```\n\nThe remote-side commands you run *inside* the Linux VM stay bash regardless of the desktop OS.\n\n---\n\n## 8. Per-workspace recipe contract (the fast path)\n\nOnce the authenticated snapshot exists, this runs on every workspace create. Define recipes in\n`orca.yaml`:\n\n```yaml\nenvironmentRecipes:\n - id: cloud-sandbox\n name: Cloud Sandbox\n create: ./scripts/orca-vm/cloud-sandbox-create.sh\n suspend: ./scripts/orca-vm/cloud-sandbox-suspend.sh\n resume: ./scripts/orca-vm/cloud-sandbox-resume.sh\n destroy: ./scripts/orca-vm/cloud-sandbox-destroy.sh\n```\n\n`create` runs **locally from the repo root** and prints **one** JSON object to stdout. Its shape depends\non the connection mode chosen in §1:\n\n**Orca-server mode** — boot the env, start `orca serve` in it, and print serve's result:\n\n```json\n{\n \"schemaVersion\": 1,\n \"pairingCode\": \"orca-pairing-code-or-url\",\n \"projectRoot\": \"/absolute/path/to/repo/on/remote\",\n \"userData\": { \"provider\": \"example\", \"resourceId\": \"provider-resource-id\" }\n}\n```\n\nHere `pairingCode` (from `orca serve --recipe-json`) and `projectRoot` are required; `schemaVersion` (`1`)\nand `userData` are optional.\n\n**SSH mode** — do **not** run `orca serve`; print the `connection.type:\"ssh\"` block instead (full shape +\nworked script in §7g). `pairingCode` is **not** used in SSH mode.\n\nLifecycle hooks (all run locally):\n\n- `create`: required. Prints recipe result JSON.\n- `suspend`: optional. Sleep; reads lifecycle payload on stdin.\n- `resume`: optional. Wake; reads payload on stdin and **prints fresh recipe JSON** (pairing may change).\n- `destroy`: optional unless `destroy: none`. Delete/cleanup; reads payload on stdin.\n\nStart Orca remotely with `orca serve --port \"$PORT\" --project-root \"$ABS_ROOT\" --pairing-address\n\"$EXTERNAL_WSS_URL\" --recipe-json` (exact flags + output in §7c). Set `--pairing-address` to the\nexternally reachable address so the emitted `pairingCode` is reachable; tunneling/port mapping is the\nscript's job.\n\nBackward compatibility: `command`→`create`, `cleanup`→`destroy`, `cleanup: none`→`destroy: none`.\nPrefer the lifecycle names.\n\n---\n\n## 9. Doctor and validation\n\nValidate in two stages — the cheap dry run first, then the live self-test.\n\n### Dry run (free, non-destructive) — always do this first\n\n`orca vm recipe doctor --repo-path --json` validates **static wiring only** — it does\n**not** boot anything. It checks: local-host execution (v1), repo path, recipe id exists,\ncreate/destroy/suspend/resume command paths resolve, suspend/resume are paired, and each script is\nexecutable (POSIX exec bit; skipped on Windows). Fix every failure here before spending any cloud money.\n\n### Live self-test (`--provision`) — diagnose and iterate yourself\n\n`orca vm recipe doctor --repo-path --provision --json` actually runs the recipe end\nto end: it executes `create`, validates the returned recipe JSON, then runs `destroy` to **tear the\nenvironment back down** (so the test leaves nothing running, as long as `destroy` works). It spends real\ncloud money, so get the user's OK **once** before starting — that one approval covers the whole loop\nbelow; do not re-ask before each run.\n\nOn failure, the JSON result includes a `provisionTranscript` with the **complete** captured output of\neach stage so you can self-diagnose without asking the user to relay logs:\n\n```json\n{\n \"ok\": false,\n \"checks\": [ { \"id\": \"recipe.provision\", \"status\": \"fail\", \"message\": \"…\" } ],\n \"provisionTranscript\": {\n \"provision\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\", \"parseError\": \"…\" },\n \"destroy\": { \"exitCode\": 0, \"signal\": null, \"stdout\": \"…\", \"stderr\": \"…\" }\n }\n}\n```\n\n**Run it as a loop:** read `provisionTranscript.provision.stderr` / `.stdout` / `.parseError` (and\n`destroy.*`), fix the script, and re-run `--provision` until `ok` is `true` — iterating on your own\nrather than waiting for the user to paste errors. Common reads: a non-empty `stderr` with `exitCode 0`\nplus a `parseError` means `create` ran but printed something other than the single recipe-result JSON on\nstdout (often a stray `echo` — route it to stderr, see §10); a non-zero `exitCode` is a provider/script\nfailure described in `stderr`. Each stream is redacted and capped (head+tail) — large logs keep both the\nsetup context and the failure.\n\nThe self-test cannot see provider-side truth beyond what the scripts print, so still confirm: state has a\npopulated **authenticated** `snapshotId` (Phases 2–3 done), and `destroy` is implemented/tested (or\nexplicitly `none` — in which case the self-test won't tear down, so clean up manually).\n\nFor SSH recipes, also smoke-test the exact emitted target before declaring success: dial the host/port\nwith the identity/proxy settings, run `pwd`, verify the repo path, check the agent binary, and confirm\n`destroy` removes the provider resource/container. For Docker, inspect the auth image entrypoint and do a\nstartup-only `docker run` before the full clone/install path.\n\n---\n\n## 10. Failure modes\n\n- **Build exceeds plan timeout (e.g. Hobby 45m).** Use enough vCPUs and a timeout covering the build;\n else split work or use a higher plan. The cap also limits per-workspace runtime — surface it.\n- **Build exceeds plan RAM.** Build the **headless main only** (drop the renderer) — the biggest fitter.\n- **Private-repo clone hangs/fails.** Wrong/missing token. Use `GIT_ASKPASS` + `GIT_TERMINAL_PROMPT=0`\n so it fails fast instead of prompting.\n- **`GIT_ASKPASS` helper aborts the clone with \"`$1: unbound variable`\".** The `printf`/heredoc that writes\n the helper inside `bash -lc` under `set -u` expanded `$1`/`$GH_TOKEN` at **write** time. Escape them\n (`\\$1`, `\\$GH_TOKEN`) so they land literally and resolve at git-runtime; this also keeps the real token\n out of the file. `rm -f` the helper afterward (§5, §7f).\n- **Agent verified as \"not logged in\" despite a good login.** `codex login status` (and similar) print\n \"Logged in …\" to **stderr**; an stdout-only `grep` misses it. Prefer the status **exit code**; if you\n grep, fold stderr first (`status 2>&1 | grep …`) and match the exact success line — not `grep -qi\n 'logged in'`, which also matches \"not logged in\".\n- **Headless agent login hangs.** Plain OAuth `login` starts a loopback callback server on a VM/container\n port the host browser can't reach. Use the **device-auth** flow (`login --device-auth`) — it prints a\n URL + code the user opens on the host.\n- **`known_hosts` host-key churn on local Docker.** Each ephemeral container regenerating its SSH host key\n collides on `127.0.0.1` as the published port rotates. Bake host keys into the base image at build time\n (`ssh-keygen -A`; runtime generates only if absent) so all containers share one stable key (§7h).\n- **Snapshot expired/evicted.** If `create` hits an unknown snapshot id, rerun Phases 2–3 and update\n `snapshotId`.\n- **Agent auth didn't persist.** Confirm `snapshotId` points at the **authenticated** snapshot; re-run\n Phase 3. Warn that short-lived tokens may need periodic re-auth.\n- **Agent auth copied from the host breaks.** Do not bind-mount/copy a full host agent home; sqlite\n files can be unwritable or host-specific, hooks may need approval again, and config may reference\n local-only env vars. Authenticate inside the runtime and snapshot/commit that layer.\n- **Docker auth image exits immediately.** Inspect `docker image inspect … .Config.Entrypoint` and\n `docker logs`. If the image was committed from an interactive shell, reset the entrypoint to the SSH\n entrypoint during `docker commit`.\n- **Leaked paid resource.** Every long script must trap errors and remove the sandbox it created.\n- **`create` emits non-JSON on stdout.** A stray `echo` corrupts the result — stdout is for the final\n JSON only; everything else to stderr. The `--provision` self-test surfaces this as `exitCode 0` + a\n `parseError` with the offending stdout in `provisionTranscript` (§9).\n\n---\n\n## 11. Boundaries\n\n- Don't create accounts, choose plans/regions, or invent scope/project/org/image/billing ids.\n- Don't invent or store credentials; no secrets in `userData`, state, comments, docs, or commits.\n- Don't run paid/long phases (base snapshot, auth, live test) without an explicit OK.\n- Don't hide provider errors behind generic messages — preserve actionable stderr.\n- Don't make Orca own provider lifecycle beyond invoking the configured scripts.\n- Don't commit or create an Orca workspace unless asked.\n" // oxfmt-ignore -const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create Orca runtime state with `orca orchestration task-create` and `orca orchestration dispatch --inject` or `orca orchestration run`.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Ownership\n\nOrchestration messages and tasks are runtime-global. Lifecycle authority comes from the payload `taskId` + `dispatchId` of the active dispatch, verified against the dispatched pane. Terminal handles are routing metadata — a pane can receive a new handle after restart — so never accept or reject lifecycle provenance by comparing handles. Send `worker_done` and `heartbeat` from the worker's own terminal; the runtime ignores them when sent from a different pane.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--unread|--peek|--all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- `check` and `check --unread` return unread matches and mark them read. Use `--peek` for unread matches without consuming them; use `--all` for read and unread history without consuming anything. If an older CLI rejects `--peek` as an unknown flag, use `--all` and filter unread rows yourself.\n- Message **one** live agent handle per worker. Use `startupTerminal.handle` from the create response when present; if it is missing or later returns `terminal_handle_stale`, re-resolve with `orca terminal list --worktree ... --json` and continue with the replacement only.\n- `orca orchestration check --unread --inject --json` renders unread mail for the agent terminal that runs it; it does not remotely wake another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,decision_gate --timeout-ms ` instead of sleep/poll loops. Reply to `decision_gate` messages with `orca orchestration reply --id --body --json`, then keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly.\n- `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also dispatch-scoped. Send it only to the concrete coordinator handle with both `taskId` and `dispatchId`; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA task is the work item, a dispatch assigns it to a terminal, and a gate blocks progress until a coordinator or user decision is recorded.\n\n```bash\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Gates And Coordinator\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\norca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]\norca orchestration run-stop [--json]\n```\n\n`run` returns immediately with a run ID. Query progress with `task-list`. Use `ask` for worker-to-coordinator questions; it creates a `decision_gate` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears runtime-global orchestration state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\n```bash\norca worktree create --name --agent codex --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from startupTerminal.handle in the create response.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `startupTerminal.handle` from `worktree create`. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Repo setup or default-terminal settings may still add tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal, even on failure:\n `orca orchestration send --to --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"filesModified\":[\"path/a\"],\"reportPath\":\"\"}' --json`\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --to --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,decision_gate --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, inspect `task-list`/`dispatch-show` if inheriting state, then choose either a manual loop (`task-create` -> worker -> `dispatch --inject` -> `check --wait`) or `orchestration run`.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" +const ORCHESTRATION_MARKDOWN = "---\nname: orchestration\ndescription: >-\n Use Orca orchestration for structured multi-agent coordination: threaded\n messages, blocking ask/reply flows, task dispatch, worker_done/escalation\n waits, task DAGs, decision gates, coordinator loops, or decomposing work\n across agents. Use `orca-cli` instead for full ownership handoffs, including\n requests phrased as \"hand off\", \"handoff\", \"handover\", \"give this to another\n agent\", or \"another worktree\" when the user did not explicitly ask to\n supervise, monitor, wait for results, or coordinate a DAG. Use `orca-cli` for\n ordinary terminal control, lightweight terminal prompts, shell commands, Orca\n worktree management, reading or waiting on terminals, and automation of the\n browser embedded inside Orca. Use Computer Use for browser windows, webviews,\n Orca app UI, or desktop UI outside Orca's embedded browser.\n---\n\n# Orca Inter-Agent Orchestration\n\nOrchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.\n\nUse this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.\n\n## Tool Boundary\n\nIf a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.\n\nDo not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.\n\nBefore claiming a worker was orchestrated, verify the task/dispatch exists:\n\n```bash\norca orchestration task-list --json\norca orchestration dispatch-show --task --json\n```\n\nIf the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.\n\n## When To Use\n\n- Send/reply/ask between agent terminals with persistent messages.\n- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.\n- Track task DAGs with dependencies.\n- Run coordinator loops or decision gates.\n\nDo not use orchestration merely because the user says \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.\n\n## Preconditions\n\n- `orca status --json` should show a running runtime.\n- `orca` must be on PATH (`orca-ide` on Linux).\n- The orchestration experimental feature must be enabled in Settings > Experimental.\n- `orca orchestration` commands are RPC calls to the running Orca runtime.\n\n## Contract Migration\n\nOrca uses a hard cutover for orchestration mutations. It does not run a legacy scheduler, translate old writes, or drain pre-upgrade orchestration work.\n\nIf a command returns `orchestration_migration_required`, `run_required`, or a lifecycle validation error with `nextCommandArgs`:\n\n1. Confirm `effectsApplied` is `false`.\n2. Using the same CLI executable that returned the error, run the returned arguments: `skills get orchestration --full`.\n3. Read the guide completely. Do not retry the rejected command unchanged.\n4. Create or bind a lightweight Run, then restart the work using Run -> Task -> `worker-start`.\n5. Inspect any pre-upgrade terminal before creating replacement work.\n\nThe arguments intentionally omit an executable name so this works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command.\n\nPre-upgrade terminals and agents are not killed during upgrade, but they are no longer supervised: old heartbeat, question, completion, scheduler, and mutation calls are rejected before effects. Legacy database rows remain available only for explicit inspection:\n\n```bash\norca orchestration run-list --json\norca orchestration run-show --id run_legacy_local --json\norca orchestration task-list --run run_legacy_local --json\norca orchestration inbox --full --json\norca orchestration check --terminal --peek --json\n```\n\nRead-only inspection never consumes legacy mail. Do not use actionable `check`, acknowledgment, send, retry, or task updates against the legacy Run.\n\n## Ownership\n\nNew orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.\n\nClassify inherited context before sending lifecycle messages:\n\n- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.\n- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.\n- Classify requests containing \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"another agent\", or \"another worktree\" as full handoffs by default, even when the user names a custom model or reasoning effort.\n- Use supervised orchestration only when the user explicitly asks you to \"supervise\", \"monitor\", \"wait\", \"track completion\", \"wait for worker_done\", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.\n- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.\n- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.\n- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.\n- If the user's plan names a next owner agent (for example, \"then use opencode to create a PR\"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.\n\nIf unclear, inspect orchestration state before sending lifecycle messages:\n\n```bash\norca orchestration task-list --json\norca terminal list --json\n# If inherited context includes a task id:\norca orchestration dispatch-show --task --json\n```\n\n## Messaging\n\n```bash\norca orchestration send --subject [--to ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--json]\norca orchestration check [--terminal ] [--ack ] [--peek|--all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\norca orchestration reply --id --body [--from ] [--json]\norca orchestration ask (--question |--resume ) [--options ] [--timeout-ms ] [--from ] [--json]\norca orchestration inbox [--limit ] [--json]\n```\n\nRules:\n\n- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.\n- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack `. Process every message before acknowledging; `check --ack --wait` acknowledges, checks, and waits in one operation.\n- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.\n- Use `dispatch:` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.\n- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.\n- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.\n- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms ` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id --body --json`, then acknowledge and keep waiting.\n- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.\n- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.\n- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.\n- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.\n- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`.\n- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.\n- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.\n- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.\n- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.\n- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.\n\n## Tasks And Dispatch\n\nA Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.\n\n```bash\norca orchestration run-create --objective --json\norca orchestration task-create --spec [--deps ] [--parent ] [--json]\norca orchestration task-list [--status ] [--ready] [--brief] [--json]\norca orchestration task-update --id --status [--result ] [--json]\norca orchestration dispatch --task --to [--from ] [--inject] [--json]\norca orchestration dispatch-show --task [--json]\n```\n\nTask statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.\n\nDispatch rules:\n\n- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.\n- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal --text --enter --json`.\n- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.\n- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.\n\n## Preferred Supervised Worker Loop\n\nUse `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.\n\nCreate the Run and every independent Task first, then start all independent workers before waiting:\n\n```bash\norca orchestration run-create --objective \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration task-create --spec \"\" --json\norca orchestration worker-start --task --worktree current --agent codex --json\norca orchestration worker-start --task --worktree current --agent claude --json\n```\n\n`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal `.\n\nFor a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:\n\n```bash\norca orchestration worker-start --task --worktree new-child --name --agent codex --setup run --json\n# Independent/top-level:\norca orchestration worker-start --task --worktree new-top-level --name --agent codex --setup run --json\n```\n\nSetup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.\n\nRead the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.\n\nTo run the worker on another connected Orca server, add `--on `. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:\n\n```bash\n# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)\norca orchestration worker-start --task --on windows --worktree new-top-level --repo --name --agent codex --setup run --json\norca orchestration worker-show --dispatch --json\norca orchestration worker-read --dispatch --limit 50 --json\norca orchestration send --to dispatch: --subject \"Follow-up\" --body \"\" --json\n```\n\nRemote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.\n\nThe follow-up is structured inbox mail, not prompt injection. The worker's next\n`orchestration check` receives it even when the Dispatch is on another connected Orca server.\n\n`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: \"terminal\"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.\n\nWait until every expected Dispatch settles, not for a fixed number of batches:\n\n```bash\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n# Process every message in the returned Delivery, then atomically ack and continue:\norca orchestration check --ack --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\nWorkers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:\n\n```bash\norca orchestration send --type worker_done --subject \"\" --body \"\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a,path/b\" --json\n# On failure, use --outcome failed; never encode failure only in prose.\n```\n\nA worker question defaults to its owning Run. Timeout leaves it pending:\n\n```bash\norca orchestration ask --question \"\" --options \"yes,no\" --timeout-ms 600000 --json\norca orchestration ask --resume --timeout-ms 600000 --json\n# Coordinator:\norca orchestration reply --id --body \"\" --json\n```\n\nRecovery is conditional, never a fixed destructive sequence:\n\n- `worker-show --dispatch ` says `ready`: keep waiting or read bounded output.\n- It proves `failed` or `stopped`: start a replacement with `worker-start --task --retry-of ` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.\n- It remains `outcome_unknown`: either `worker-stop --dispatch ` and inspect again, or explicitly `worker-abandon --dispatch ` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.\n- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.\n\nLow-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.\n\n## Gates And Legacy Inspection\n\n```bash\norca orchestration gate-create --task --question [--options ] [--json]\norca orchestration gate-resolve --id --resolution [--json]\norca orchestration gate-list [--task ] [--status ] [--json]\n```\n\nUse `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.\n\n`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.\n\nRecovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.\n\n## Full Handoffs\n\nFor full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.\n\nTreat these as full handoff requests by default: \"hand off\", \"handoff\", \"handover\", \"give this to another agent\", \"give this to another worktree\", \"send this to another agent\", \"another agent\", \"another worktree\", or \"launch another agent to own this.\" Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.\n\nSupervised orchestration remains available only when the user explicitly asks for supervision or coordination: \"supervise\", \"monitor\", \"wait for worker_done\", \"wait for results\", \"track completion\", \"DAG\", \"decision gate\", \"ask/reply\", or \"coordinate workers.\"\n\nDo not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.\n\nNew top-level worktree handoff:\n\n```bash\norca worktree create --name --no-parent --agent codex --prompt \"\" --setup run --json\n```\n\nBefore creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.\n\nExisting terminal handoff:\n\n```bash\norca terminal send --terminal --text \"\" --enter --json\n```\n\nCustom Codex model/effort handoff:\n\n`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.\n\nThe two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.\n\nNote: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.\n\nUse the exact full `::` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.\n\n```bash\norca worktree create --name --no-parent --setup run --json\norca terminal create --worktree id: --title --command 'codex --model gpt-5.5 -c model_reasoning_effort=\"xhigh\"' --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca terminal send --terminal --text \"\" --enter --json\n```\n\nWait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.\n\n`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or \"branch from current\". Put current-branch context in the prompt instead.\n\n## Worker Terminals\n\nChoose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:\n\n```bash\norca terminal create --worktree active --title --command \"codex\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nReuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.\n\nWhen a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.\n\nFor every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.\n\n```bash\norca worktree create --name --agent codex --setup run --json\n# or: --agent claude | omp | pi | grok | ...\n# Read from agentTerminalHandle, falling back to startupTerminal.handle.\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration dispatch --task --to --inject --json\n```\n\nFor new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo `.\n\n**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command ` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.\n\nUse `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.\n\nSidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.\n\nOther terminal commands coordinators often need:\n\n```bash\norca terminal list [--worktree ] [--json]\norca terminal create [--worktree ] [--title ] [--command ] [--json]\norca terminal split --terminal [--direction horizontal|vertical] [--command ] [--json]\norca terminal wait --terminal --for tui-idle --timeout-ms --json\norca terminal read --terminal --json\norca terminal send --terminal --text --enter --json\n```\n\nIf an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree --command \"codex\" --json` or `--command \"claude\"`.\n\nWait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.\n\n## Agent Guidance\n\n- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:\n `orca orchestration send --type worker_done --subject \"\" --body \"<3-sentence summary: what you did, what you found, what's left>\" --task-id --dispatch-id --outcome succeeded --files-modified \"path/a\" --report-path \"\" --json`\n- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.\n- After sending `worker_done`, end your turn and idle at the agent prompt. Do not poll or keep calling `orca orchestration check`; the coordinator re-engages you with a fresh preamble + TASK block delivered as new terminal input.\n- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:\n `orca orchestration send --type heartbeat --subject \"alive\" --payload '{\"taskId\":\"\",\"dispatchId\":\"\",\"phase\":\"implementing\"}' --json`\n- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.\n- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.\n- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.\n\n## Example\n\n```bash\norca terminal create --worktree active --title login-css-worker --command \"claude\" --json\norca terminal wait --terminal --for tui-idle --timeout-ms 60000 --json\norca orchestration task-create --spec \"Fix the login button CSS\" --json\norca orchestration dispatch --task --to --inject --json\norca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json\n```\n\n## Next Action\n\nCoordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology.\n\nWorker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.\n" // Why: no current guide has bundled reference documents, so --full is byte-identical for now. // oxfmt-ignore diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index b5996261416..f4e30166d72 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -1,30 +1,6 @@ import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime-client' -import { CORE_HANDLERS } from './handlers/core' -import { AUTOMATION_HANDLERS } from './handlers/automations' -import { PROJECT_HANDLERS } from './handlers/project' -import { REPO_HANDLERS } from './handlers/repo' -import { WORKTREE_HANDLERS } from './handlers/worktree' -import { FILE_HANDLERS } from './handlers/file' -import { TERMINAL_HANDLERS } from './handlers/terminal' -import { BROWSER_NAV_HANDLERS } from './handlers/browser-nav' -import { BROWSER_INTERACT_HANDLERS } from './handlers/browser-interact' -import { BROWSER_TAB_HANDLERS } from './handlers/browser-tab' -import { BROWSER_PROFILE_HANDLERS } from './handlers/browser-profile' -import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie' -import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture' -import { BROWSER_ENV_HANDLERS } from './handlers/browser-env' -import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage' -import { ORCHESTRATION_HANDLERS } from './handlers/orchestration' -import { COMPUTER_HANDLERS } from './handlers/computer' -import { ENVIRONMENT_HANDLERS } from './handlers/environment' -import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks' -import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics' -import { INTROSPECTION_HANDLERS } from './handlers/introspection' -import { EMULATOR_HANDLERS } from './handlers/emulator' -import { LINEAR_HANDLERS } from './handlers/linear' -import { VM_HANDLERS } from './handlers/vm' -import { SKILL_HANDLERS } from './handlers/skills' +import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' export type HandlerContext = { flags: Map @@ -36,56 +12,46 @@ export type HandlerContext = { export type CommandHandler = (ctx: HandlerContext) => Promise -function buildHandlers(): Map { - const table = new Map() - const groups = [ - CORE_HANDLERS, - AUTOMATION_HANDLERS, - PROJECT_HANDLERS, - REPO_HANDLERS, - WORKTREE_HANDLERS, - FILE_HANDLERS, - TERMINAL_HANDLERS, - BROWSER_NAV_HANDLERS, - BROWSER_INTERACT_HANDLERS, - BROWSER_TAB_HANDLERS, - BROWSER_PROFILE_HANDLERS, - BROWSER_COOKIE_HANDLERS, - BROWSER_CAPTURE_HANDLERS, - BROWSER_ENV_HANDLERS, - BROWSER_STORAGE_HANDLERS, - ORCHESTRATION_HANDLERS, - EMULATOR_HANDLERS, - COMPUTER_HANDLERS, - AGENT_HOOK_HANDLERS, - DIAGNOSTICS_HANDLERS, - INTROSPECTION_HANDLERS, - ENVIRONMENT_HANDLERS, - LINEAR_HANDLERS, - VM_HANDLERS, - SKILL_HANDLERS - ] +// Why: routing only needs key→group, so every CLI invocation can skip the +// transitive module graph of the 24 groups it does not dispatch into. +function buildRoutes(groups: readonly HandlerGroup[]): Map { + const table = new Map() for (const group of groups) { - for (const [key, handler] of Object.entries(group)) { - if (table.has(key)) { - throw new Error(`Duplicate CLI handler registration for "${key}"`) + for (const key of group.keys) { + const owner = table.get(key) + if (owner) { + throw new Error( + `Duplicate CLI handler registration for "${key}" (${owner.name} and ${group.name})` + ) } - table.set(key, handler) + table.set(key, group) } } return table } -const HANDLERS = buildHandlers() +const ROUTES = buildRoutes(HANDLER_GROUPS) // Why: exposes only the canonical command keys (not the handler internals) so the // registry-parity guard can check specs↔handlers without rebuilding the table. -export const HANDLER_COMMAND_KEYS: ReadonlySet = new Set(HANDLERS.keys()) +export const HANDLER_COMMAND_KEYS: ReadonlySet = new Set(ROUTES.keys()) export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise { - const handler = HANDLERS.get(commandPath.join(' ')) + const key = commandPath.join(' ') + const group = ROUTES.get(key) + if (!group) { + throw new RuntimeClientError('invalid_argument', `Unknown command: ${key}`) + } + const handler = (await group.load())[key] + // Why: the manifest key list is verified against the real exports in CI, so a + // miss here means the group changed without the manifest — fail loudly. if (!handler) { - throw new RuntimeClientError('invalid_argument', `Unknown command: ${commandPath.join(' ')}`) + throw new RuntimeClientError( + 'invalid_argument', + `CLI handler group "${group.name}" does not export "${key}"` + ) } await handler(ctx) } + +export { buildRoutes as buildHandlerRoutes } diff --git a/src/cli/format.test.ts b/src/cli/format.test.ts index 7117b169512..ce26baeda1a 100644 --- a/src/cli/format.test.ts +++ b/src/cli/format.test.ts @@ -12,7 +12,8 @@ import { formatTerminalList, formatTerminalRead, formatWorktreeList, - printResult + printResult, + reportCliError } from './format' import type { ComputerActionResult, RuntimeWorktreeRecord } from '../shared/runtime-types' import type { Automation } from '../shared/automations-types' @@ -116,6 +117,38 @@ describe('formatCliError', () => { ].join('\n') ) }) + + it('preserves orchestration migration recovery in human and JSON errors', () => { + const error = new RuntimeRpcFailureError({ + id: 'req_migration', + ok: false, + error: { + code: 'orchestration_migration_required', + message: 'No effects were applied.', + data: { + effectsApplied: false, + nextCommandArgs: ['skills', 'get', 'orchestration', '--full'], + nextSteps: ['Using this same Orca CLI executable, run: skills get orchestration --full'] + } + }, + _meta: { runtimeId: 'runtime-1' } + }) + + expect(formatCliError(error)).toContain( + 'Next step: Using this same Orca CLI executable, run: skills get orchestration --full' + ) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + reportCliError(error, true) + expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({ + error: { + code: 'orchestration_migration_required', + data: { + effectsApplied: false, + nextCommandArgs: ['skills', 'get', 'orchestration', '--full'] + } + } + }) + }) }) describe('formatWorktreeList', () => { diff --git a/src/cli/handler-group-manifest.test.ts b/src/cli/handler-group-manifest.test.ts new file mode 100644 index 00000000000..d17c3446db1 --- /dev/null +++ b/src/cli/handler-group-manifest.test.ts @@ -0,0 +1,121 @@ +import { readdirSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +import { buildHandlerRoutes, dispatch, type HandlerContext } from './dispatch' +import { HANDLER_GROUPS, type HandlerGroup } from './handler-group-manifest' + +// Why: dispatch trusts the manifest's eager key lists to route without loading a +// group. These tests are the only thing standing between that trust and a +// silently unreachable command, so they load every group for real. + +describe('handler group manifest', () => { + it('lists a loadable group for every entry', async () => { + for (const group of HANDLER_GROUPS) { + const loaded = await group.load() + expect(loaded, `${group.name} resolved to a non-record`).toBeTypeOf('object') + } + }) + + it('matches each group export key-for-key', async () => { + const drift: string[] = [] + for (const group of HANDLER_GROUPS) { + const actual = Object.keys(await group.load()).sort() + const declared = [...group.keys].sort() + if (JSON.stringify(actual) !== JSON.stringify(declared)) { + drift.push( + `${group.name}: manifest ${JSON.stringify(declared)} !== export ${JSON.stringify(actual)}` + ) + } + } + expect(drift).toEqual([]) + }) + + it('exposes every declared key as a callable handler', async () => { + const notCallable: string[] = [] + for (const group of HANDLER_GROUPS) { + const loaded = await group.load() + for (const key of group.keys) { + if (typeof loaded[key] !== 'function') { + notCallable.push(`${group.name}/${key}`) + } + } + } + expect(notCallable).toEqual([]) + }) + + it('reaches every group through dispatch routing', () => { + const routes = buildHandlerRoutes(HANDLER_GROUPS) + const reached = new Set([...routes.values()].map((group) => group.name)) + const unreachable = HANDLER_GROUPS.filter((group) => !reached.has(group.name)).map( + (group) => group.name + ) + expect(unreachable).toEqual([]) + }) + + // Why: dropping a group from the manifest silently unregisters its commands — + // scan the directory so a new or forgotten handler file fails here, not in prod. + it('registers every handler module that exports a handler group', async () => { + // Why: __dirname works under both Vitest and the CommonJS tsc emit that + // build:cli type-checks this file against; import.meta.dirname does not. + const dir = join(__dirname, 'handlers') + const modules = readdirSync(dir).filter( + (file) => file.endsWith('.ts') && !file.endsWith('.test.ts') + ) + const registered = new Set(HANDLER_GROUPS.map((group) => group.name)) + const missing: string[] = [] + for (const file of modules) { + const name = file.slice(0, -'.ts'.length) + const exports: Record = await import(join(dir, file)) + const exportsGroup = Object.keys(exports).some((key) => key.endsWith('_HANDLERS')) + if (exportsGroup && !registered.has(name)) { + missing.push(name) + } + } + expect(missing).toEqual([]) + }) +}) + +describe('duplicate command keys', () => { + const group = (name: string, keys: string[]): HandlerGroup => ({ + name, + keys, + load: async () => ({}) + }) + + it('rejects the same key claimed by two groups', () => { + expect(() => + buildHandlerRoutes([group('alpha', ['ship it']), group('beta', ['ship it'])]) + ).toThrow('Duplicate CLI handler registration for "ship it" (alpha and beta)') + }) + + it('rejects a key duplicated inside one group list', () => { + expect(() => buildHandlerRoutes([group('alpha', ['ship it', 'ship it'])])).toThrow( + 'Duplicate CLI handler registration for "ship it"' + ) + }) + + it('accepts distinct keys across groups', () => { + const routes = buildHandlerRoutes([group('alpha', ['a']), group('beta', ['b'])]) + expect([...routes.keys()]).toEqual(['a', 'b']) + }) + + it('holds for the live manifest', () => { + expect(() => buildHandlerRoutes(HANDLER_GROUPS)).not.toThrow() + }) +}) + +describe('dispatch errors', () => { + const ctx = { + flags: new Map(), + cwd: '/tmp', + json: false + } as unknown as HandlerContext + + it('reports an unknown command with the joined path', async () => { + await expect(dispatch(['not', 'a', 'command'], ctx)).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Unknown command: not a command' + }) + }) +}) diff --git a/src/cli/handler-group-manifest.ts b/src/cli/handler-group-manifest.ts new file mode 100644 index 00000000000..ffe608ccde7 --- /dev/null +++ b/src/cli/handler-group-manifest.ts @@ -0,0 +1,224 @@ +import type { CommandHandler } from './dispatch' +import { BROWSER_HANDLER_GROUPS } from './browser-handler-groups' + +export type HandlerGroup = { + name: string + // Why: eager string keys let dispatch build (and duplicate-check) the whole + // command table without loading any group's transitive module graph. + keys: readonly string[] + load: () => Promise> +} + +// Why: `keys` mirrors each group's exported record and is verified against the +// real exports by handler-group-manifest.test.ts, so drift fails CI, not dispatch. +export const HANDLER_GROUPS: readonly HandlerGroup[] = [ + { + name: 'core', + keys: ['claude-teams', 'open', 'serve', 'status'], + load: async () => (await import('./handlers/core.js')).CORE_HANDLERS + }, + { + name: 'automations', + keys: [ + 'automations list', + 'automations show', + 'automations create', + 'automations edit', + 'automations remove', + 'automations run', + 'automations runs' + ], + load: async () => (await import('./handlers/automations.js')).AUTOMATION_HANDLERS + }, + { + name: 'project', + keys: [ + 'project list', + 'project setups', + 'project setup-existing-folder', + 'project setup-clone', + 'project setup-create', + 'project setup-update', + 'project setup-delete' + ], + load: async () => (await import('./handlers/project.js')).PROJECT_HANDLERS + }, + { + name: 'repo', + keys: ['repo list', 'repo add', 'repo show', 'repo set-base-ref', 'repo search-refs'], + load: async () => (await import('./handlers/repo.js')).REPO_HANDLERS + }, + { + name: 'worktree', + keys: [ + 'worktree ps', + 'worktree list', + 'worktree show', + 'worktree current', + 'worktree create', + 'worktree set', + 'worktree rm' + ], + load: async () => (await import('./handlers/worktree.js')).WORKTREE_HANDLERS + }, + { + name: 'file', + keys: ['file open', 'file diff', 'file open-changed'], + load: async () => (await import('./handlers/file.js')).FILE_HANDLERS + }, + { + name: 'terminal', + keys: [ + 'terminal list', + 'terminal show', + 'terminal read', + 'terminal send', + 'terminal wait', + 'terminal stop', + 'terminal rename', + 'terminal create', + 'terminal switch', + 'terminal close', + 'terminal split' + ], + load: async () => (await import('./handlers/terminal.js')).TERMINAL_HANDLERS + }, + ...BROWSER_HANDLER_GROUPS, + { + name: 'orchestration', + keys: [ + 'orchestration run-create', + 'orchestration run-use', + 'orchestration run-current', + 'orchestration run-list', + 'orchestration run-show', + 'orchestration send', + 'orchestration check', + 'orchestration reply', + 'orchestration inbox', + 'orchestration task-create', + 'orchestration task-list', + 'orchestration task-update', + 'orchestration worker-start', + 'orchestration worker-show', + 'orchestration worker-read', + 'orchestration worker-stop', + 'orchestration worker-abandon', + 'orchestration dispatch', + 'orchestration ask', + 'orchestration dispatch-show', + 'orchestration coordinator-start', + 'orchestration coordinator-stop', + 'orchestration gate-create', + 'orchestration gate-resolve', + 'orchestration gate-list', + 'orchestration reset' + ], + load: async () => (await import('./handlers/orchestration.js')).ORCHESTRATION_HANDLERS + }, + { + name: 'emulator', + keys: [ + 'emulator list', + 'emulator devices', + 'emulator attach', + 'emulator tap', + 'emulator type', + 'emulator gesture', + 'emulator button', + 'emulator rotate', + 'emulator exec', + 'emulator kill', + 'emulator shutdown', + 'emulator install', + 'emulator launch', + 'emulator permissions', + 'emulator ax', + 'emulator logcat' + ], + load: async () => (await import('./handlers/emulator.js')).EMULATOR_HANDLERS + }, + { + name: 'computer', + keys: [ + 'computer capabilities', + 'computer list-apps', + 'computer permissions', + 'computer list-windows', + 'computer get-app-state', + 'computer click', + 'computer perform-secondary-action', + 'computer scroll', + 'computer drag', + 'computer type-text', + 'computer press-key', + 'computer hotkey', + 'computer paste-text', + 'computer set-value' + ], + load: async () => (await import('./handlers/computer.js')).COMPUTER_HANDLERS + }, + { + name: 'agent-hooks', + keys: ['agent hooks status', 'agent hooks off', 'agent hooks on'], + load: async () => (await import('./handlers/agent-hooks.js')).AGENT_HOOK_HANDLERS + }, + { + name: 'diagnostics', + keys: ['diagnostics memory'], + load: async () => (await import('./handlers/diagnostics.js')).DIAGNOSTICS_HANDLERS + }, + { + name: 'introspection', + keys: ['agent-context'], + load: async () => (await import('./handlers/introspection.js')).INTROSPECTION_HANDLERS + }, + { + name: 'environment', + keys: ['environment add', 'environment list', 'environment show', 'environment rm'], + load: async () => (await import('./handlers/environment.js')).ENVIRONMENT_HANDLERS + }, + { + name: 'linear', + keys: [ + 'linear save-issue', + 'linear list-issues', + 'linear relation add', + 'linear relation remove', + 'linear issue', + 'linear search', + 'linear team list', + 'linear team members', + 'linear team states', + 'linear team labels', + 'linear project list', + 'linear list', + 'linear status set', + 'linear assignee set', + 'linear assignee clear', + 'linear priority set', + 'linear priority clear', + 'linear estimate set', + 'linear estimate clear', + 'linear due-date set', + 'linear due-date clear', + 'linear label add', + 'linear label remove', + 'linear label set', + 'linear comment add', + 'linear attach', + 'linear create' + ], + load: async () => (await import('./handlers/linear.js')).LINEAR_HANDLERS + }, + { + name: 'vm', + keys: ['vm recipe doctor'], + load: async () => (await import('./handlers/vm.js')).VM_HANDLERS + }, + { + name: 'skills', + keys: ['skills list', 'skills get'], + load: async () => (await import('./handlers/skills.js')).SKILL_HANDLERS + } +] diff --git a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts index 269dfd7a47a..c55eed4391d 100644 --- a/src/cli/handlers/orchestration-lifecycle-rejection.test.ts +++ b/src/cli/handlers/orchestration-lifecycle-rejection.test.ts @@ -30,7 +30,8 @@ it('prints a lifecycle rejection and exits unsuccessfully', async () => { ['from', 'term_foreign'], ['to', 'term_coord'], ['subject', 'done'], - ['type', 'worker_done'] + ['type', 'worker_done'], + ['outcome', 'succeeded'] ]), client: { call: callMock }, cwd: '/tmp/repo', diff --git a/src/cli/handlers/orchestration-migration.test.ts b/src/cli/handlers/orchestration-migration.test.ts new file mode 100644 index 00000000000..8c092dbef83 --- /dev/null +++ b/src/cli/handlers/orchestration-migration.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +describe('orchestration CLI migration recovery', () => { + it('redirects worker_done without an outcome before resolving or calling the runtime', async () => { + const call = vi.fn() + + await expect( + ORCHESTRATION_HANDLERS['orchestration send']({ + flags: new Map([ + ['from', 'term_worker'], + ['subject', 'Done'], + ['type', 'worker_done'] + ]), + client: { call }, + cwd: '/tmp/repo', + json: true + } as never) + ).rejects.toMatchObject({ + code: 'invalid_argument', + data: { + effectsApplied: false, + nextCommandArgs: ['skills', 'get', 'orchestration', '--full'] + } + }) + expect(call).not.toHaveBeenCalled() + }) +}) diff --git a/src/cli/handlers/orchestration-run-cli.test.ts b/src/cli/handlers/orchestration-run-cli.test.ts new file mode 100644 index 00000000000..84f4dc4c659 --- /dev/null +++ b/src/cli/handlers/orchestration-run-cli.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() +const getTerminalHandleMock = vi.hoisted(() => vi.fn()) + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock })) + +import { printResult } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +describe('lightweight Run CLI handlers', () => { + beforeEach(() => { + callMock.mockReset() + getTerminalHandleMock.mockReset() + process.env.ORCA_TERMINAL_HANDLE = 'term_coord' + }) + + it('creates a Run with the resolved coordinator terminal', async () => { + callMock.mockResolvedValue({ + result: { run: { id: 'run_1', objective: 'Coordinate work', consumer_generation: 1 } } + }) + await ORCHESTRATION_HANDLERS['orchestration run-create']({ + flags: new Map([ + ['objective', 'Coordinate work'], + ['json', true] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + expect(callMock).toHaveBeenCalledWith('orchestration.runCreate', { + objective: 'Coordinate work', + from: 'term_coord' + }) + }) + + it('reuses the same explicit binding path for run-use and run-current', async () => { + callMock + .mockResolvedValueOnce({ result: { run: { id: 'run_1', objective: 'Work' } } }) + .mockResolvedValueOnce({ result: { run: { id: 'run_1', objective: 'Work' } } }) + await ORCHESTRATION_HANDLERS['orchestration run-use']({ + flags: new Map([ + ['id', 'run_1'], + ['from', 'term_coord'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + await ORCHESTRATION_HANDLERS['orchestration run-current']({ + flags: new Map([['from', 'term_coord']]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + expect(callMock).toHaveBeenNthCalledWith(1, 'orchestration.runUse', { + id: 'run_1', + from: 'term_coord' + }) + expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.runCurrent', { + from: 'term_coord' + }) + }) +}) + +describe('orchestration reset CLI handler', () => { + beforeEach(() => { + callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } }) + }) + const invoke = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration reset']({ + flags, + client: { call: callMock }, + json: true + } as never) + + it('rejects a bare reset before calling the runtime', async () => { + await expect(invoke(new Map())).rejects.toMatchObject({ + code: 'invalid_argument', + message: 'Choose exactly one reset scope: --all, --tasks, or --messages.' + }) + expect(callMock).not.toHaveBeenCalled() + }) + + it('sends only the tasks scope for --tasks', async () => { + await invoke(new Map([['tasks', true]])) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: undefined, + tasks: true, + messages: undefined + }) + }) + + it('sends only the all scope for --all', async () => { + await invoke(new Map([['all', true]])) + expect(callMock).toHaveBeenCalledWith('orchestration.reset', { + all: true, + tasks: undefined, + messages: undefined + }) + }) + + it.each([ + new Map([ + ['tasks', true], + ['messages', true] + ]), + new Map([ + ['all', true], + ['tasks', true] + ]) + ])('rejects multiple reset scopes before calling the runtime', async (flags) => { + await expect(invoke(flags)).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(callMock).not.toHaveBeenCalled() + }) +}) + +describe('orchestration task-list brief output', () => { + it('requests server-side brief and falls back client-side for older runtimes', async () => { + callMock.mockReset().mockResolvedValue({ + result: { + tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }], + count: 1 + } + }) + vi.mocked(printResult).mockClear() + await ORCHESTRATION_HANDLERS['orchestration task-list']({ + flags: new Map([['brief', true]]), + client: { call: callMock }, + json: true + } as never) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.taskList', + expect.objectContaining({ brief: true }) + ) + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { tasks: { spec: string; spec_truncated: boolean }[] } + } + expect(response.result.tasks[0].spec).toHaveLength(160) + expect(response.result.tasks[0].spec_truncated).toBe(true) + }) + + it('passes server-abbreviated rows through untouched', async () => { + const serverTasks = [ + { id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true } + ] + callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } }) + vi.mocked(printResult).mockClear() + await ORCHESTRATION_HANDLERS['orchestration task-list']({ + flags: new Map([['brief', true]]), + client: { call: callMock }, + json: true + } as never) + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { tasks: { spec: string; spec_truncated: boolean }[] } + } + expect(response.result.tasks).toBe(serverTasks) + }) +}) diff --git a/src/cli/handlers/orchestration-timeout-cli.test.ts b/src/cli/handlers/orchestration-timeout-cli.test.ts new file mode 100644 index 00000000000..54282345a6c --- /dev/null +++ b/src/cli/handlers/orchestration-timeout-cli.test.ts @@ -0,0 +1,230 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { printResult } from '../format' +import { ORCHESTRATION_HANDLERS } from './orchestration' + +describe('orchestration timeout flag validation', () => { + const invalidTimeoutValues: [string, string | boolean][] = [ + ['missing', true], + ['empty', ''], + ['non-numeric', 'not-a-number'], + ['zero', '0'], + ['negative', '-1'] + ] + + beforeEach(() => { + callMock.mockReset() + delete process.env.ORCA_TERMINAL_HANDLE + delete process.env.ORCA_PANE_KEY + }) + + const invokeCheck = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration check']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + const invokeAsk = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration ask']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { + await expect( + invokeCheck( + new Map([ + ['wait', true], + ['timeout-ms', value] + ]) + ) + ).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('passes a parsed check timeout and peek mode into the RPC payload', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) + await invokeCheck( + new Map([ + ['wait', true], + ['peek', true], + ['timeout-ms', '250'] + ]) + ) + // Why: unread:false makes pre-peek runtimes fall back to non-consuming all mode. + expect(callMock).toHaveBeenCalledWith('orchestration.check', { + terminal: 'term_worker', + unread: false, + peek: true, + all: undefined, + types: undefined, + format: undefined, + run: undefined, + ack: undefined, + wait: true, + timeoutMs: 250 + }) + }) + + it('filters already-read rows from a peek response for pre-peek runtimes', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [ + { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, + { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } + ], + count: 2, + formatted: 'banners built from all rows' + } + }) + vi.mocked(printResult).mockClear() + await invokeCheck(new Map([['peek', true]])) + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { messages: { id: string }[]; count: number; formatted?: string } + } + expect(response.result.messages.map((message) => message.id)).toEqual(['msg_new']) + expect(response.result.count).toBe(1) + expect(response.result.formatted).toBeUndefined() + }) + + it('rejects combined read modes before calling the runtime', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + await expect( + invokeCheck( + new Map([ + ['unread', true], + ['peek', true] + ]) + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringContaining('read mode') + }) + expect(callMock).not.toHaveBeenCalled() + }) + + it('warns when a pre-peek runtime returned a full 100-row page', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + const rows = Array.from({ length: 100 }, (_, index) => ({ + id: `msg_${index}`, + from_handle: 'a', + subject: `s${index}`, + read: index === 0 ? 0 : 1 + })) + callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + await invokeCheck(new Map([['peek', true]])) + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) + errorSpy.mockRestore() + }) + + it('fails --peek --wait against a runtime that returned only read rows', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], + count: 1 + } + }) + await expect( + invokeCheck( + new Map([ + ['peek', true], + ['wait', true] + ]) + ) + ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) + }) + + it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { + await expect( + invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', value] + ]) + ) + ).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', '123'] + ]) + ) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + { + to: 'term_coord', + run: undefined, + question: 'Proceed?', + resume: undefined, + options: undefined, + timeoutMs: 123, + from: 'term_worker' + }, + { timeoutMs: 5_123, orchestrationCapability: undefined } + ) + }) + + it('passes an ask resume without creating a new question payload', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_question', + threadId: 'msg_question', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + await invokeAsk(new Map([['resume', 'msg_question']])) + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + { + to: undefined, + run: undefined, + question: undefined, + resume: 'msg_question', + options: undefined, + timeoutMs: undefined, + from: 'term_worker' + }, + { timeoutMs: 605_000, orchestrationCapability: undefined } + ) + }) + + it('rejects ambiguous ask create/resume input before RPC', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + await expect( + invokeAsk( + new Map([ + ['question', 'new'], + ['resume', 'msg_old'] + ]) + ) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(callMock).not.toHaveBeenCalled() + }) +}) diff --git a/src/cli/handlers/orchestration-worker-cli.test.ts b/src/cli/handlers/orchestration-worker-cli.test.ts new file mode 100644 index 00000000000..09b4a7a34b9 --- /dev/null +++ b/src/cli/handlers/orchestration-worker-cli.test.ts @@ -0,0 +1,161 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const callMock = vi.fn() +const originalExitCode = process.exitCode + +vi.mock('../format', () => ({ printResult: vi.fn() })) +vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() })) + +import { ORCHESTRATION_HANDLERS } from './orchestration' + +describe('orchestration worker-start CLI contract', () => { + beforeEach(() => { + callMock.mockReset() + process.exitCode = undefined + }) + + afterEach(() => { + process.exitCode = originalExitCode + }) + + const invokeWorkerStart = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration worker-start']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it('passes the complete supported creation contract and retry receipt', async () => { + callMock.mockResolvedValue({ + result: { + runId: 'run_1', + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'ready', + effects: [], + residualResources: [] + } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['on', 'windows'], + ['worktree', 'new-top-level'], + ['name', 'release-audit'], + ['repo', 'id:windows-repo'], + ['base-branch', 'origin/release'], + ['display-name', 'Release audit'], + ['comment', 'Supervised from the Mac Run home'], + ['setup', 'run'], + ['agent', 'codex'], + ['timeout-ms', '90000'], + ['run', 'run_1'], + ['from', 'term_coord'], + ['retry-request', 'request_1'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.workerStart', + { + task: 'task_1', + on: 'windows', + worktree: 'new-top-level', + name: 'release-audit', + repo: 'id:windows-repo', + baseBranch: 'origin/release', + displayName: 'Release audit', + comment: 'Supervised from the Mac Run home', + setup: 'run', + agent: 'codex', + terminal: undefined, + retryOf: undefined, + timeoutMs: 90_000, + run: 'run_1', + from: 'term_coord', + devMode: false + }, + { orchestrationRequestId: 'request_1' } + ) + expect(process.exitCode).toBeUndefined() + }) + + it('sets an unsuccessful exit code for failed and unknown receipts', async () => { + callMock.mockResolvedValue({ + result: { + taskId: 'task_1', + dispatchId: 'ctx_1', + state: 'outcome_unknown', + effects: [], + residualResources: [] + } + }) + + await invokeWorkerStart( + new Map([ + ['task', 'task_1'], + ['agent', 'codex'], + ['from', 'term_coord'] + ]) + ) + + expect(process.exitCode).toBe(1) + }) + + it('allows the initial zero cursor when paging worker output', async () => { + callMock.mockResolvedValue({ + result: { + dispatchId: 'ctx_1', + terminal: { tail: [], status: 'running', nextCursor: '0' } + } + }) + + await ORCHESTRATION_HANDLERS['orchestration worker-read']({ + flags: new Map([ + ['dispatch', 'ctx_1'], + ['cursor', '0'], + ['limit', '100'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledWith('orchestration.workerRead', { + dispatch: 'ctx_1', + cursor: 0, + limit: 100, + source: undefined + }) + }) + + it('passes opaque source-pinned cursors and explicit source selection', async () => { + callMock.mockResolvedValue({ + result: { + dispatchId: 'ctx_1', + source: 'transcript', + transcript: { messages: [], nextCursor: 'owr1_next' } + } + }) + + await ORCHESTRATION_HANDLERS['orchestration worker-read']({ + flags: new Map([ + ['dispatch', 'ctx_1'], + ['cursor', 'owr1_previous'], + ['source', 'transcript'] + ]), + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + expect(callMock).toHaveBeenCalledWith('orchestration.workerRead', { + dispatch: 'ctx_1', + cursor: 'owr1_previous', + limit: undefined, + source: 'transcript' + }) + }) +}) diff --git a/src/cli/handlers/orchestration.test.ts b/src/cli/handlers/orchestration.test.ts index 55f2482f5cc..d5399ece4aa 100644 --- a/src/cli/handlers/orchestration.test.ts +++ b/src/cli/handlers/orchestration.test.ts @@ -5,7 +5,7 @@ const getTerminalHandleMock = vi.hoisted(() => vi.fn()) const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE const originalPaneKey = process.env.ORCA_PANE_KEY function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { - return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.` + return `${type} messages belong to one exact Dispatch and cannot target a group address.` } // Why: isolate the handler's flag-to-param mapping; printResult only writes output. @@ -48,46 +48,6 @@ afterEach(() => { } }) -describe('orchestration reset CLI handler', () => { - beforeEach(() => { - callMock.mockReset().mockResolvedValue({ result: { reset: 'all' } }) - }) - - const invoke = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration reset']({ - flags, - client: { call: callMock }, - json: true - } as never) - - it('sends all: true for a bare `reset` (no scope flag)', async () => { - await invoke(new Map()) - expect(callMock).toHaveBeenCalledWith('orchestration.reset', { - all: true, - tasks: undefined, - messages: undefined - }) - }) - - it('sends only the tasks scope for --tasks', async () => { - await invoke(new Map([['tasks', true]])) - expect(callMock).toHaveBeenCalledWith('orchestration.reset', { - all: undefined, - tasks: true, - messages: undefined - }) - }) - - it('sends only the all scope for --all (no implicit extra scopes)', async () => { - await invoke(new Map([['all', true]])) - expect(callMock).toHaveBeenCalledWith('orchestration.reset', { - all: true, - tasks: undefined, - messages: undefined - }) - }) -}) - describe('orchestration send structured payload flags', () => { beforeEach(() => { callMock.mockReset().mockResolvedValue({ result: { message: { id: 'msg_1' } } }) @@ -113,6 +73,7 @@ describe('orchestration send structured payload flags', () => { ['type', 'worker_done'], ['task-id', 'task_1'], ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'], ['files-modified', 'src/a.ts, src/b.ts'], ['report-path', 'reports/done.md'] ]) @@ -129,6 +90,7 @@ describe('orchestration send structured payload flags', () => { payload: JSON.stringify({ taskId: 'task_1', dispatchId: 'ctx_1', + outcome: 'succeeded', filesModified: ['src/a.ts', 'src/b.ts'], reportPath: 'reports/done.md' }), @@ -151,6 +113,25 @@ describe('orchestration send structured payload flags', () => { expect(callMock).toHaveBeenCalledWith('orchestration.send', expect.objectContaining({ body })) }) + it('carries Dispatch authority in the RPC envelope instead of message params', async () => { + await invokeSend( + new Map([ + ['from', 'term_worker'], + ['subject', 'alive'], + ['type', 'heartbeat'], + ['dispatch-id', 'ctx_1'], + ['dispatch-capability', 'dcap_secret'], + ['retry-request', 'mutation_1'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.send', + expect.not.objectContaining({ dispatchCapability: expect.anything() }), + { orchestrationCapability: 'dcap_secret', orchestrationRequestId: 'mutation_1' } + ) + }) + it('rejects mixing raw payload with structured payload flags', async () => { await expect( invokeSend( @@ -212,7 +193,8 @@ describe('orchestration send structured payload flags', () => { ['from', 'term_worker'], ['to', 'term_coord'], ['subject', 'done'], - ['type', 'worker_done'] + ['type', 'worker_done'], + ['outcome', 'succeeded'] ]) ) @@ -224,7 +206,7 @@ describe('orchestration send structured payload flags', () => { type: 'worker_done', priority: undefined, threadId: undefined, - payload: undefined, + payload: JSON.stringify({ outcome: 'succeeded' }), devMode: false }) }) @@ -236,7 +218,8 @@ describe('orchestration send structured payload flags', () => { new Map([ ['to', 'term_coord'], ['subject', 'done'], - ['type', 'worker_done'] + ['type', 'worker_done'], + ['outcome', 'succeeded'] ]) ) @@ -249,7 +232,7 @@ describe('orchestration send structured payload flags', () => { type: 'worker_done', priority: undefined, threadId: undefined, - payload: undefined, + payload: JSON.stringify({ outcome: 'succeeded' }), devMode: false }) }) @@ -264,7 +247,8 @@ describe('orchestration send structured payload flags', () => { new Map([ ['to', 'term_coord'], ['subject', 'update'], - ['type', type] + ['type', type], + ...(type === 'worker_done' ? ([['outcome', 'succeeded']] as const) : []) ]) ) @@ -288,7 +272,8 @@ describe('orchestration send structured payload flags', () => { new Map([ ['to', 'term_coord'], ['subject', 'done'], - ['type', 'worker_done'] + ['type', 'worker_done'], + ['outcome', 'succeeded'] ]) ) @@ -308,7 +293,8 @@ describe('orchestration send structured payload flags', () => { new Map([ ['to', 'term_coord'], ['subject', 'done'], - ['type', 'worker_done'] + ['type', 'worker_done'], + ['outcome', 'succeeded'] ]) ) ).rejects.toMatchObject({ @@ -328,7 +314,8 @@ describe('orchestration send structured payload flags', () => { new Map([ ['to', 'term_coord'], ['subject', 'update'], - ['type', type] + ['type', type], + ...(type === 'worker_done' ? ([['outcome', 'succeeded']] as const) : []) ]) ) ).rejects.toMatchObject({ code: 'no_active_sender_terminal' }) @@ -364,7 +351,7 @@ describe('orchestration dispatch coordinator handle', () => { } as never) const invokeRun = (flags: Map) => - ORCHESTRATION_HANDLERS['orchestration run']({ + ORCHESTRATION_HANDLERS['orchestration coordinator-start']({ flags, client: { call: callMock }, cwd: '/tmp/repo', @@ -483,29 +470,18 @@ describe('orchestration dispatch coordinator handle', () => { }) }) - it('uses a live coordinator handle for orchestration runs', async () => { - process.env.ORCA_TERMINAL_HANDLE = 'term_stale_coord' - process.env.ORCA_PANE_KEY = 'tab_coord:leaf_coord' - stubStaleHandleRemint('term_live_coord', { - result: { runId: 'run_1', status: 'running' } - }) - getTerminalHandleMock.mockRejectedValue(new Error('active terminal fallback is unsafe')) - - await invokeRun(new Map([['spec', 'run the plan']])) - - expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { - terminal: 'term_stale_coord' - }) - expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', { - paneKey: 'tab_coord:leaf_coord' - }) - expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.run', { - spec: 'run the plan', - from: 'term_live_coord', - pollIntervalMs: undefined, - maxConcurrent: undefined, - worktree: undefined + it('retires the legacy coordinator command without runtime effects', async () => { + await expect( + invokeRun(new Map([['spec', 'run the plan']])) + ).rejects.toMatchObject({ + code: 'orchestration_migration_required', + data: { + reason: 'command_retired', + effectsApplied: false, + nextCommandArgs: ['skills', 'get', 'orchestration', '--full'] + } }) + expect(callMock).not.toHaveBeenCalled() }) }) @@ -540,75 +516,58 @@ describe('orchestration task-create caller handle', () => { displayName: undefined, deps: undefined, parent: undefined, + run: undefined, callerTerminalHandle: 'term_creator' }) }) - it('does not persist a stale env terminal handle as task creator', async () => { + it('fails closed when a stale task creator handle cannot be reminted', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_stale' - callMock - .mockRejectedValueOnce(staleHandleError()) - .mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } }) + callMock.mockRejectedValueOnce(staleHandleError()) getTerminalHandleMock.mockResolvedValue('term_wrong_active') - await invokeTaskCreate(new Map([['spec', 'do work']])) + await expect( + invokeTaskCreate(new Map([['spec', 'do work']])) + ).rejects.toMatchObject({ code: 'no_active_sender_terminal' }) expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' }) expect(getTerminalHandleMock).not.toHaveBeenCalled() - expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.taskCreate', { - spec: 'do work', - taskTitle: undefined, - displayName: undefined, - deps: undefined, - parent: undefined, - callerTerminalHandle: undefined - }) + expect(callMock).toHaveBeenCalledTimes(1) }) - it('does not fail task creation when env handle validation cannot inspect the graph', async () => { + it('propagates runtime unavailability while proving the bound coordinator', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_creator' - callMock - .mockRejectedValueOnce(new RuntimeClientError('runtime_unavailable', 'runtime_unavailable')) - .mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } }) + callMock.mockRejectedValueOnce( + new RuntimeClientError('runtime_unavailable', 'runtime_unavailable') + ) - await invokeTaskCreate(new Map([['spec', 'do work']])) + await expect( + invokeTaskCreate(new Map([['spec', 'do work']])) + ).rejects.toMatchObject({ code: 'runtime_unavailable' }) expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_creator' }) expect(getTerminalHandleMock).not.toHaveBeenCalled() - expect(callMock).toHaveBeenNthCalledWith(2, 'orchestration.taskCreate', { - spec: 'do work', - taskTitle: undefined, - displayName: undefined, - deps: undefined, - parent: undefined, - callerTerminalHandle: undefined - }) + expect(callMock).toHaveBeenCalledTimes(1) }) - it('omits caller handle when pane reminting cannot inspect the graph', async () => { + it('propagates runtime unavailability while reminting the bound coordinator', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_stale' process.env.ORCA_PANE_KEY = 'tab_creator:leaf_creator' stubStaleHandleRemintFailure( new RuntimeClientError('runtime_unavailable', 'runtime_unavailable') ) - callMock.mockResolvedValueOnce({ result: { task: { id: 'task_1', status: 'ready' } } }) getTerminalHandleMock.mockResolvedValue('term_wrong_active') - await invokeTaskCreate(new Map([['spec', 'do work']])) + await expect( + invokeTaskCreate(new Map([['spec', 'do work']])) + ).rejects.toMatchObject({ code: 'runtime_unavailable' }) expect(callMock).toHaveBeenNthCalledWith(1, 'terminal.show', { terminal: 'term_stale' }) expect(callMock).toHaveBeenNthCalledWith(2, 'terminal.resolvePane', { paneKey: 'tab_creator:leaf_creator' }) expect(getTerminalHandleMock).not.toHaveBeenCalled() - expect(callMock).toHaveBeenNthCalledWith(3, 'orchestration.taskCreate', { - spec: 'do work', - taskTitle: undefined, - displayName: undefined, - deps: undefined, - parent: undefined, - callerTerminalHandle: undefined - }) + expect(callMock).toHaveBeenCalledTimes(2) }) it('propagates unexpected caller pane remint failures for task creation', async () => { @@ -665,10 +624,248 @@ describe('orchestration task-create caller handle', () => { displayName: undefined, deps: undefined, parent: undefined, + run: undefined, callerTerminalHandle: 'term_live' }) }) }) +describe('orchestration timeout flag validation', () => { + const invalidTimeoutValues: [string, string | boolean][] = [ + ['missing', true], + ['empty', ''], + ['non-numeric', 'not-a-number'], + ['zero', '0'], + ['negative', '-1'] + ] + + beforeEach(() => { + callMock.mockReset() + delete process.env.ORCA_TERMINAL_HANDLE + delete process.env.ORCA_PANE_KEY + }) + + const invokeCheck = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration check']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + const invokeAsk = (flags: Map) => + ORCHESTRATION_HANDLERS['orchestration ask']({ + flags, + client: { call: callMock }, + cwd: '/tmp/repo', + json: true + } as never) + + it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => { + const flags = new Map([ + ['wait', true], + ['timeout-ms', value] + ]) + + await expect(invokeCheck(flags)).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('passes a parsed check timeout and peek mode into the RPC payload', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ result: { messages: [], count: 0 } }) + + await invokeCheck( + new Map([ + ['wait', true], + ['peek', true], + ['timeout-ms', '250'] + ]) + ) + + // Why: --peek rides with unread:false so pre-peek runtimes fall back to + // the non-consuming all mode instead of the destructive mark-read default. + expect(callMock).toHaveBeenCalledWith('orchestration.check', { + terminal: 'term_worker', + unread: false, + peek: true, + all: undefined, + types: undefined, + format: undefined, + run: undefined, + ack: undefined, + wait: true, + timeoutMs: 250 + }) + }) + + it('filters already-read rows from a peek response for pre-peek runtimes', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [ + { id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }, + { id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 } + ], + count: 2, + formatted: 'banners built from all rows' + } + }) + vi.mocked(printResult).mockClear() + + await invokeCheck(new Map([['peek', true]])) + + const response = vi.mocked(printResult).mock.calls[0]?.[0] as { + result: { messages: { id: string }[]; count: number; formatted?: string } + } + expect(response.result.messages.map((m) => m.id)).toEqual(['msg_new']) + expect(response.result.count).toBe(1) + // Why: the pre-peek runtime built `formatted` from all rows, including + // the read one the filter just removed. + expect(response.result.formatted).toBeUndefined() + }) + + it('rejects combined read modes before calling the runtime', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockClear() + + await expect( + invokeCheck( + new Map([ + ['unread', true], + ['peek', true] + ]) + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: expect.stringContaining('read mode') + }) + expect(callMock).not.toHaveBeenCalled() + }) + + it('warns when a pre-peek runtime returned a full 100-row page', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + const rows = Array.from({ length: 100 }, (_, i) => ({ + id: `msg_${i}`, + from_handle: 'a', + subject: `s${i}`, + read: i === 0 ? 0 : 1 + })) + callMock.mockResolvedValue({ result: { messages: rows, count: 100 } }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await invokeCheck(new Map([['peek', true]])) + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages')) + errorSpy.mockRestore() + }) + + it('fails --peek --wait against a runtime that returned only read rows', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }], + count: 1 + } + }) + + await expect( + invokeCheck( + new Map([ + ['peek', true], + ['wait', true] + ]) + ) + ).rejects.toMatchObject({ code: 'peek_wait_unsupported' }) + }) + + it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => { + const flags = new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', value] + ]) + + await expect(invokeAsk(flags)).rejects.toThrow(/--timeout-ms/) + expect(callMock).not.toHaveBeenCalled() + }) + + it('uses the parsed ask timeout for both runtime wait and client timeout', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_1', + threadId: 'thread_1', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk( + new Map([ + ['to', 'term_coord'], + ['question', 'Proceed?'], + ['timeout-ms', '123'] + ]) + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + { + to: 'term_coord', + run: undefined, + question: 'Proceed?', + resume: undefined, + options: undefined, + timeoutMs: 123, + from: 'term_worker' + }, + { timeoutMs: 5_123, orchestrationCapability: undefined } + ) + }) + + it('passes an ask resume without creating a new question payload', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + callMock.mockResolvedValue({ + result: { + answer: 'yes', + messageId: 'msg_question', + threadId: 'msg_question', + timedOut: false + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await invokeAsk(new Map([['resume', 'msg_question']])) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.ask', + { + to: undefined, + run: undefined, + question: undefined, + resume: 'msg_question', + options: undefined, + timeoutMs: undefined, + from: 'term_worker' + }, + { timeoutMs: 605_000, orchestrationCapability: undefined } + ) + }) + + it('rejects ambiguous ask create/resume input before RPC', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_worker' + await expect( + invokeAsk( + new Map([ + ['question', 'new'], + ['resume', 'msg_old'] + ]) + ) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + expect(callMock).not.toHaveBeenCalled() + }) +}) describe('orchestration task-list brief output', () => { it('requests server-side brief and falls back client-side for older runtimes', async () => { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index ec02ded26d2..220f0964251 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */ import type { CommandHandler } from '../dispatch' +import type { RuntimeClient } from '../runtime-client' import { printResult } from '../format' import { getOptionalPositiveIntegerFlag, @@ -14,11 +15,21 @@ import { } from '../../shared/orchestration-ask-timeout' import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary' import { parsePositiveSafeIntegerText } from '../../shared/timer-delay' +import type { + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../shared/orchestration-worker-output' +import type { NativeChatMessage } from '../../shared/native-chat-types' +import type { RuntimeTerminalRead } from '../../shared/runtime-types' +import { + orchestrationMigrationData, + orchestrationSkillRecoveryData +} from '../../shared/orchestration-rpc-contract' // Why: 15 s is well under Claude Code's ~2 min Bash-tool silence budget while keeping log volume low. See design doc §3.4. const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000 function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { - return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.` + return `${type} messages belong to one exact Dispatch and cannot target a group address.` } // Why: test-only escape hatch so subprocess tests avoid the full 15 s window; bogus values fall back to the default. @@ -83,6 +94,16 @@ type LifecycleSendRejection = { type OrchestrationSendResult = | { message: { id: string }; lifecycle?: LifecycleSendRejection } | { messages: { id: string }[]; recipients: number } + | { + relay: { + messageId: string + sequence: number + dispatchId: string + destination?: 'run_home' | 'worker' + accepted: true + } + lifecycle?: { action: 'completed' | 'failed' } + } function getOptionalStructuredMessagePayload( flags: Map @@ -90,12 +111,14 @@ function getOptionalStructuredMessagePayload( const rawPayload = getOptionalStringFlag(flags, 'payload') const taskId = getOptionalStringFlag(flags, 'task-id') const dispatchId = getOptionalStringFlag(flags, 'dispatch-id') + const outcome = getOptionalStringFlag(flags, 'outcome') const filesModified = getOptionalStringFlag(flags, 'files-modified') const reportPath = getOptionalStringFlag(flags, 'report-path') const phase = getOptionalStringFlag(flags, 'phase') const hasStructuredPayload = taskId !== undefined || dispatchId !== undefined || + outcome !== undefined || filesModified !== undefined || reportPath !== undefined || phase !== undefined @@ -116,6 +139,15 @@ function getOptionalStructuredMessagePayload( if (dispatchId) { payload.dispatchId = dispatchId } + if (outcome) { + if (outcome !== 'succeeded' && outcome !== 'failed') { + throw new RuntimeClientError( + 'invalid_argument', + 'Invalid --outcome. Expected succeeded or failed.' + ) + } + payload.outcome = outcome + } if (filesModified) { payload.filesModified = filesModified .split(',') @@ -163,29 +195,6 @@ async function resolveOrchestrationTerminalHandle( return await getTerminalHandle(flags, cwd, client) } -async function resolveTaskCreatorTerminalHandle( - client: Parameters[0]['client'] -): Promise { - const envHandle = process.env.ORCA_TERMINAL_HANDLE - if (!envHandle || envHandle.length === 0) { - return undefined - } - let live: boolean - try { - live = await isLiveTerminalHandle(envHandle, client) - } catch (err) { - if (isOptionalTaskCreatorHandleError(err)) { - // Why: creator handles are best-effort lineage metadata; graph unavailability must not block task creation. - return undefined - } - throw err - } - if (live) { - return envHandle - } - return await resolveOrchestrationPaneTerminalHandle(client, { optional: true }) -} - async function isLiveTerminalHandle( handle: string, client: Parameters[0]['client'] @@ -218,11 +227,6 @@ function isNoActiveTerminalError(err: unknown): boolean { return getClientErrorCode(err) === 'no_active_terminal' } -function isOptionalTaskCreatorHandleError(err: unknown): boolean { - const code = getClientErrorCode(err) - return code === 'no_active_sender_terminal' || code === 'runtime_unavailable' -} - async function resolveOrchestrationPaneTerminalHandle( client: Parameters[0]['client'], options: { optional?: boolean } = {} @@ -310,7 +314,10 @@ function throwNoActiveSenderTerminal(): never { } function isDevCliInvocation(): boolean { - return process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false + return ( + process.env.ORCA_DEV_CLI_INVOCATION === '1' || + (process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false) + ) } function getOptionalPositiveIntegerValueFlag( @@ -340,11 +347,148 @@ function rejectLifecycleGroupRecipient(type: string | undefined, to: string): vo } } +function callMutation( + client: RuntimeClient, + flags: Map, + method: string, + params: unknown, + options?: { timeoutMs?: number; orchestrationCapability?: string } +) { + const requestId = getOptionalStringFlag(flags, 'retry-request') + if (!requestId) { + return options + ? client.call(method, params, options) + : client.call(method, params) + } + return client.call(method, params, { + ...options, + orchestrationRequestId: requestId + }) +} + +type LegacyWorkerReadResult = { + dispatchId: string + terminal: RuntimeTerminalRead +} + +function formatWorkerRead(value: OrchestrationWorkerReadResult | LegacyWorkerReadResult): string { + if (!('source' in value) || value.source === 'terminal') { + return value.terminal.tail.join('\n') + } + return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n') +} + +function formatWorkerTranscriptMessage(message: NativeChatMessage): string { + const blocks = message.blocks.map((block) => { + if (block.type === 'text') { + return block.text + } + if (block.type === 'tool-call') { + return `[tool ${block.name}] ${safeJson(block.input)}` + } + if (block.type === 'tool-result') { + return `[tool result${block.isError ? ' error' : ''}] ${block.output}` + } + return block.url ? `[image] ${block.url}` : `[image omitted]` + }) + return `[${message.role}] ${blocks.join('\n')}`.trimEnd() +} + +function safeJson(value: unknown): string { + try { + return JSON.stringify(value) + } catch { + return '[unserializable input]' + } +} + export const ORCHESTRATION_HANDLERS: Record = { + 'orchestration run-create': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runCreate', { + objective: getRequiredStringFlag(flags, 'objective'), + from + }) + printResult(result, json, (r) => `Run ${r.run.id} created and bound: ${r.run.objective}`) + }, + + 'orchestration run-use': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callMutation<{ + run: { id: string; objective: string; consumer_generation: number } + }>(client, flags, 'orchestration.runUse', { + id: getRequiredStringFlag(flags, 'id'), + from + }) + printResult(result, json, (r) => `Using Run ${r.run.id}: ${r.run.objective}`) + }, + + 'orchestration run-current': async ({ flags, client, cwd, json }) => { + const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await client.call<{ + run: { id: string; objective: string } | null + }>('orchestration.runCurrent', { from }) + printResult(result, json, (r) => + r.run ? `${r.run.id} ${r.run.objective}` : 'No Run is bound to this terminal.' + ) + }, + + 'orchestration run-list': async ({ client, json }) => { + const result = await client.call<{ + runs: { id: string; objective: string; legacy: number }[] + }>('orchestration.runList', {}) + printResult(result, json, (r) => + r.runs.length === 0 + ? 'No Runs found.' + : r.runs + .map( + (run) => `${run.id}${run.legacy ? ' [legacy, inspect only]' : ''} ${run.objective}` + ) + .join('\n') + ) + }, + + 'orchestration run-show': async ({ flags, client, json }) => { + const result = await client.call<{ + run: { + id: string + objective: string + consumer_generation: number + legacy: number + created_at: string + } + }>('orchestration.runShow', { id: getRequiredStringFlag(flags, 'id') }) + printResult( + result, + json, + (r) => + `${r.run.id}${r.run.legacy ? ' [legacy, inspect only]' : ''} ${r.run.objective}\n` + + `consumer generation ${r.run.consumer_generation}; created ${r.run.created_at}` + ) + }, + 'orchestration send': async ({ flags, client, cwd, json }) => { - const to = getRequiredStringFlag(flags, 'to') + const to = getOptionalStringFlag(flags, 'to') const type = getOptionalStringFlag(flags, 'type') - rejectLifecycleGroupRecipient(type, to) + if (to) { + rejectLifecycleGroupRecipient(type, to) + } + const outcome = getOptionalStringFlag(flags, 'outcome') + if (type === 'worker_done' && outcome === undefined && !flags.has('payload')) { + throw new RuntimeClientError( + 'invalid_argument', + 'worker_done requires --outcome succeeded or --outcome failed. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } + if (type !== 'worker_done' && outcome !== undefined) { + throw new RuntimeClientError( + 'invalid_argument', + '--outcome is only valid with --type worker_done.' + ) + } if ( (type === 'worker_done' || type === 'heartbeat') && @@ -357,9 +501,10 @@ export const ORCHESTRATION_HANDLERS: Record = { // Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness probe (worker_done must survive the mid-restart window) and no remint (older runtimes require from === the stale assignee_handle). const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await client.call('orchestration.send', { + const sendParams = { from, to, + run: getOptionalStringFlag(flags, 'run'), subject: getRequiredStringFlag(flags, 'subject'), body: getOptionalStringFlag(flags, 'body'), type, @@ -369,7 +514,15 @@ export const ORCHESTRATION_HANDLERS: Record = { // Why: pane key is the remint-stable sender identity the runtime verifies lifecycle ownership against; older runtimes strip it. senderPaneKey: process.env.ORCA_PANE_KEY || undefined, devMode: isDevCliInvocation() - }) + } + const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability') + const result = await callMutation( + client, + flags, + 'orchestration.send', + sendParams, + dispatchCapability ? { orchestrationCapability: dispatchCapability } : undefined + ) if ('message' in result.result && result.result.lifecycle?.action === 'rejected') { // Why: a rejected lifecycle signal isn't completion; non-zero exit stops workers from treating it as such. process.exitCode = 1 @@ -381,6 +534,12 @@ export const ORCHESTRATION_HANDLERS: Record = { } return `Sent ${r.message.id}` } + if ('relay' in r) { + if (r.relay.destination === 'worker') { + return `Queued ${r.relay.messageId} for worker Dispatch ${r.relay.dispatchId}` + } + return `Queued ${r.relay.messageId} for Run home (Dispatch ${r.relay.dispatchId})` + } return `Sent ${r.messages.length} messages to ${r.recipients} recipients` }) }, @@ -404,17 +563,24 @@ export const ORCHESTRATION_HANDLERS: Record = { messages: MessageSummary[] count: number formatted?: string + deliveryId?: string | null + runId?: string + timedOut?: boolean + cancelled?: boolean + connectionLost?: boolean } let result: Awaited>> try { - result = await client.call('orchestration.check', { + result = await callMutation(client, flags, 'orchestration.check', { terminal, // Why: peek also sends unread:false so pre-peek runtimes degrade to non-consuming all mode instead of destructive mark-read. unread: flags.has('unread') ? true : peek ? false : undefined, peek: peek ? true : undefined, all: flags.has('all') ? true : undefined, types: getOptionalStringFlag(flags, 'types'), - inject: flags.has('inject') ? true : undefined, + format: flags.has('format') ? true : undefined, + run: getOptionalStringFlag(flags, 'run'), + ack: getOptionalStringFlag(flags, 'ack'), wait: wait ? true : undefined, timeoutMs }) @@ -454,21 +620,36 @@ export const ORCHESTRATION_HANDLERS: Record = { return r.formatted } if (r.count === 0) { + if (r.timedOut) { + return 'Wait timed out; no messages were consumed.' + } + if (r.cancelled) { + return r.connectionLost + ? 'Wait cancelled because the connection closed; no messages were consumed.' + : 'Wait cancelled; no messages were consumed.' + } return 'No messages.' } - return r.messages + const rendered = r.messages .map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`) .join('\n') + return r.deliveryId ? `Delivery ${r.deliveryId}\n${rendered}` : rendered }) }, 'orchestration reply': async ({ flags, client, cwd, json }) => { const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await client.call<{ message: { id: string } }>('orchestration.reply', { - id: getRequiredStringFlag(flags, 'id'), - body: getRequiredStringFlag(flags, 'body'), - from - }) + const result = await callMutation<{ message: { id: string } }>( + client, + flags, + 'orchestration.reply', + { + id: getRequiredStringFlag(flags, 'id'), + body: getRequiredStringFlag(flags, 'body'), + run: getOptionalStringFlag(flags, 'run'), + from + } + ) printResult(result, json, (r) => `Replied ${r.message.id}`) }, @@ -505,9 +686,11 @@ export const ORCHESTRATION_HANDLERS: Record = { }) }, - 'orchestration task-create': async ({ flags, client, json }) => { - const callerTerminalHandle = await resolveTaskCreatorTerminalHandle(client) - const result = await client.call<{ task: { id: string; status: string } }>( + 'orchestration task-create': async ({ flags, client, cwd, json }) => { + const callerTerminalHandle = await resolveCoordinatorTerminalHandle(flags, cwd, client) + const result = await callMutation<{ task: { id: string; status: string } }>( + client, + flags, 'orchestration.taskCreate', { spec: getRequiredStringFlag(flags, 'spec'), @@ -515,14 +698,19 @@ export const ORCHESTRATION_HANDLERS: Record = { displayName: getOptionalStringFlag(flags, 'display-name'), deps: getOptionalStringFlag(flags, 'deps'), parent: getOptionalStringFlag(flags, 'parent'), + run: getOptionalStringFlag(flags, 'run'), callerTerminalHandle } ) printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`) }, - 'orchestration task-list': async ({ flags, client, json }) => { + 'orchestration task-list': async ({ flags, client, cwd, json }) => { const brief = flags.has('brief') + const run = getOptionalStringFlag(flags, 'run') + const callerTerminalHandle = run + ? undefined + : await resolveCoordinatorTerminalHandle(flags, cwd, client) const result = await client.call<{ tasks: { id: string @@ -535,10 +723,14 @@ export const ORCHESTRATION_HANDLERS: Record = { spec_truncated?: boolean }[] count: number + runId?: string + legacyReadOnly?: boolean }>('orchestration.taskList', { status: getOptionalStringFlag(flags, 'status'), ready: flags.has('ready') ? true : undefined, - brief: brief ? true : undefined + brief: brief ? true : undefined, + run, + callerTerminalHandle }) // Why: only older runtimes (no spec_truncated) skip server-side abbreviation and need this client-side fallback. const needsClientAbbreviation = @@ -551,9 +743,9 @@ export const ORCHESTRATION_HANDLERS: Record = { : result printResult(output, json, (r) => { if (r.count === 0) { - return 'No tasks.' + return r.legacyReadOnly ? 'No legacy tasks (read-only).' : 'No tasks.' } - return r.tasks + const tasks = r.tasks .map((t) => { const label = t.display_name ?? t.task_title ?? t.spec const head = `${t.id} [${t.status}] ${label.slice(0, 60)}` @@ -563,10 +755,11 @@ export const ORCHESTRATION_HANDLERS: Record = { return head }) .join('\n') + return r.legacyReadOnly ? `Legacy Run ${r.runId} (read-only)\n${tasks}` : tasks }) }, - 'orchestration task-update': async ({ flags, client, json }) => { + 'orchestration task-update': async ({ flags, client, cwd, json }) => { const status = getRequiredStringFlag(flags, 'status') if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) { throw new RuntimeClientError( @@ -574,30 +767,149 @@ export const ORCHESTRATION_HANDLERS: Record = { `invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}` ) } - const result = await client.call<{ task: { id: string; status: string } }>( + const result = await callMutation<{ task: { id: string; status: string } }>( + client, + flags, 'orchestration.taskUpdate', { id: getRequiredStringFlag(flags, 'id'), status, - result: getOptionalStringFlag(flags, 'result') + result: getOptionalStringFlag(flags, 'result'), + run: getOptionalStringFlag(flags, 'run'), + callerTerminalHandle: await resolveCoordinatorTerminalHandle(flags, cwd, client) } ) printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`) }, + 'orchestration worker-start': async ({ flags, client, cwd, json }) => { + const result = await callMutation<{ + runId: string + taskId: string + dispatchId: string + state: string + failedStage?: string + lastError?: string + effects: unknown[] + residualResources: unknown[] + }>(client, flags, 'orchestration.workerStart', { + task: getRequiredStringFlag(flags, 'task'), + on: getOptionalStringFlag(flags, 'on'), + worktree: getOptionalStringFlag(flags, 'worktree'), + name: getOptionalStringFlag(flags, 'name'), + repo: getOptionalStringFlag(flags, 'repo'), + baseBranch: getOptionalStringFlag(flags, 'base-branch'), + displayName: getOptionalStringFlag(flags, 'display-name'), + comment: getOptionalStringFlag(flags, 'comment'), + setup: getOptionalStringFlag(flags, 'setup'), + agent: getOptionalStringFlag(flags, 'agent'), + terminal: getOptionalStringFlag(flags, 'terminal'), + retryOf: getOptionalStringFlag(flags, 'retry-of'), + timeoutMs: getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms'), + run: getOptionalStringFlag(flags, 'run'), + from: await resolveCoordinatorTerminalHandle(flags, cwd, client), + devMode: isDevCliInvocation() + }) + if (result.result.state !== 'ready') { + process.exitCode = 1 + } + printResult(result, json, (worker) => { + const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}` + return worker.lastError + ? `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}` + : base + }) + }, + + 'orchestration worker-show': async ({ flags, client, json }) => { + const result = await client.call<{ + dispatch: { id: string; task_id: string; status: string } + worker: { state: string; stage: string; agent_terminal_handle: string | null } + }>('orchestration.workerShow', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult( + result, + json, + (value) => + `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}` + ) + }, + + 'orchestration worker-read': async ({ flags, client, json }) => { + const cursorFlag = getOptionalStringFlag(flags, 'cursor') + const cursor = + cursorFlag !== undefined && /^\d+$/.test(cursorFlag) + ? Number.parseInt(cursorFlag, 10) + : cursorFlag + const source = getOptionalStringFlag(flags, 'source') + if (source && !['auto', 'transcript', 'terminal'].includes(source)) { + throw new RuntimeClientError( + 'invalid_argument', + '--source must be auto, transcript, or terminal' + ) + } + const result = await client.call( + 'orchestration.workerRead', + { + dispatch: getRequiredStringFlag(flags, 'dispatch'), + cursor, + limit: getOptionalPositiveIntegerFlag(flags, 'limit'), + source: source as OrchestrationWorkerReadSource | undefined + } + ) + printResult(result, json, formatWorkerRead) + }, + + 'orchestration worker-stop': async ({ flags, client, json }) => { + const result = await callMutation<{ + dispatchId: string + state: string + processAction: string + lastError?: string + }>(client, flags, 'orchestration.workerStop', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + if (result.result.state === 'stop_unknown') { + process.exitCode = 1 + } + printResult( + result, + json, + (value) => + `Worker ${value.dispatchId} [${value.state}] process=${value.processAction}${value.lastError ? `\n${value.lastError}` : ''}` + ) + }, + + 'orchestration worker-abandon': async ({ flags, client, json }) => { + const result = await callMutation<{ + dispatchId: string + state: string + warning: string + }>(client, flags, 'orchestration.workerAbandon', { + dispatch: getRequiredStringFlag(flags, 'dispatch') + }) + printResult( + result, + json, + (value) => `Worker ${value.dispatchId} [${value.state}]\nWarning: ${value.warning}` + ) + }, + 'orchestration dispatch': async ({ flags, client, cwd, json }) => { const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) const dryRun = flags.has('dry-run') ? true : undefined const returnPreamble = flags.has('return-preamble') ? true : undefined // Why: --to is only required for non-dry-run; the RPC handler re-enforces. const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to') - const result = await client.call<{ + const result = await callMutation<{ dispatch: { id: string; task_id: string; status: string } | null injected?: boolean dryRun?: boolean preamble?: string - }>('orchestration.dispatch', { + }>(client, flags, 'orchestration.dispatch', { task: getRequiredStringFlag(flags, 'task'), + run: getOptionalStringFlag(flags, 'run'), to, from, inject: flags.has('inject') ? true : undefined, @@ -618,23 +930,46 @@ export const ORCHESTRATION_HANDLERS: Record = { const parsedTimeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms') const timeoutMs = clampOrchestrationAskTimeoutMs(parsedTimeoutMs) const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from') - const result = await client.call<{ + const question = getOptionalStringFlag(flags, 'question') + const resume = getOptionalStringFlag(flags, 'resume') + if ((question ? 1 : 0) + (resume ? 1 : 0) !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one of --question or --resume.' + ) + } + if (resume && flags.has('options')) { + throw new RuntimeClientError( + 'invalid_argument', + '--options is only valid when creating a new question.' + ) + } + const result = await callMutation<{ answer: string | null messageId: string | null threadId: string timedOut: boolean timeoutMs?: number + cancelled?: boolean + connectionLost?: boolean }>( + client, + flags, 'orchestration.ask', { - to: getRequiredStringFlag(flags, 'to'), - question: getRequiredStringFlag(flags, 'question'), + to: getOptionalStringFlag(flags, 'to'), + run: getOptionalStringFlag(flags, 'run'), + question, + resume, options: getOptionalStringFlag(flags, 'options'), timeoutMs: parsedTimeoutMs === undefined ? undefined : timeoutMs, from }, // Why: extend past timeoutMs so the RPC transport's 60s default doesn't abort before the runtime's own timeout resolves. - { timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs) } + { + timeoutMs: resolveOrchestrationAskClientTimeoutMs(parsedTimeoutMs), + orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability') + } ) // Why: bypass printResult so --json emits a bare JSON object (no envelope) pipeable via `jq -r .answer`, unlike other verbs. if (json) { @@ -650,6 +985,16 @@ export const ORCHESTRATION_HANDLERS: Record = { } process.exitCode = 1 } + if (result.result.cancelled) { + if (!json) { + console.error( + result.result.connectionLost + ? `ask connection closed (question ${result.result.messageId})` + : `ask cancelled (question ${result.result.messageId})` + ) + } + process.exitCode = 1 + } }, 'orchestration dispatch-show': async ({ flags, client, cwd, json }) => { @@ -678,33 +1023,26 @@ export const ORCHESTRATION_HANDLERS: Record = { }) }, - 'orchestration run': async ({ flags, client, cwd, json }) => { - const from = await resolveCoordinatorTerminalHandle(flags, cwd, client) - const result = await client.call<{ - runId: string - status: string - }>('orchestration.run', { - spec: getRequiredStringFlag(flags, 'spec'), - from, - pollIntervalMs: getOptionalPositiveIntegerFlag(flags, 'poll-interval-ms'), - maxConcurrent: getOptionalPositiveIntegerFlag(flags, 'max-concurrent'), - worktree: getOptionalStringFlag(flags, 'worktree') - }) - printResult(result, json, (r) => `Run ${r.runId} started (${r.status})`) + 'orchestration coordinator-start': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) }, - 'orchestration run-stop': async ({ client, json }) => { - const result = await client.call<{ - runId: string - stopped: boolean - }>('orchestration.runStop', {}) - printResult(result, json, (r) => `Run ${r.runId} stopped`) + 'orchestration coordinator-stop': async () => { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The legacy automatic coordinator command is retired. No effects were applied.', + orchestrationMigrationData('command_retired') + ) }, 'orchestration gate-create': async ({ flags, client, json }) => { - const result = await client.call<{ + const result = await callMutation<{ gate: { id: string; task_id: string; status: string } - }>('orchestration.gateCreate', { + }>(client, flags, 'orchestration.gateCreate', { task: getRequiredStringFlag(flags, 'task'), question: getRequiredStringFlag(flags, 'question'), options: getOptionalStringFlag(flags, 'options') @@ -717,9 +1055,9 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration gate-resolve': async ({ flags, client, json }) => { - const result = await client.call<{ + const result = await callMutation<{ gate: { id: string; task_id: string; status: string; resolution: string } - }>('orchestration.gateResolve', { + }>(client, flags, 'orchestration.gateResolve', { id: getRequiredStringFlag(flags, 'id'), resolution: getRequiredStringFlag(flags, 'resolution') }) @@ -745,9 +1083,17 @@ export const ORCHESTRATION_HANDLERS: Record = { }, 'orchestration reset': async ({ flags, client, json }) => { - const hasScopeFlag = flags.has('all') || flags.has('tasks') || flags.has('messages') - const result = await client.call<{ reset: string }>('orchestration.reset', { - all: flags.has('all') || !hasScopeFlag ? true : undefined, + const scopeCount = [flags.has('all'), flags.has('tasks'), flags.has('messages')].filter( + Boolean + ).length + if (scopeCount !== 1) { + throw new RuntimeClientError( + 'invalid_argument', + 'Choose exactly one reset scope: --all, --tasks, or --messages.' + ) + } + const result = await callMutation<{ reset: string }>(client, flags, 'orchestration.reset', { + all: flags.has('all') ? true : undefined, tasks: flags.has('tasks') ? true : undefined, messages: flags.has('messages') ? true : undefined }) diff --git a/src/cli/help.ts b/src/cli/help.ts index f133c95d2af..e7d5c3c24ac 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -85,8 +85,14 @@ Terminals: terminal close Close a terminal pane/session, or its whole tab with --tab Orchestration: + orchestration run-create Create and bind a lightweight orchestration Run + orchestration run-use Bind this coordinator terminal to an existing Run + orchestration run-current Show this terminal's bound Run + orchestration run-list List lightweight orchestration Runs + orchestration run-show Show one lightweight orchestration Run orchestration send Send an inter-agent message - orchestration check Check messages for a terminal + orchestration check Check the bound Run mailbox + orchestration ask Ask the coordinator a blocking question orchestration reply Reply to a message orchestration inbox Show all messages across recipients orchestration task-create Create an orchestration task @@ -94,8 +100,13 @@ Orchestration: orchestration task-update Update a task status orchestration dispatch Dispatch a task to a terminal orchestration dispatch-show Show dispatch context for a task - orchestration run Start the coordinator loop - orchestration run-stop Stop the active coordinator run + orchestration worker-start Start a supervised worker locally or on a connected Orca server + orchestration worker-show Inspect one supervised worker + orchestration worker-read Read bounded output from one supervised worker + orchestration worker-stop Stop one supervised worker + orchestration worker-abandon Fence an uncertain worker without claiming it stopped + orchestration coordinator-start Start the legacy automatic coordinator loop + orchestration coordinator-stop Stop the legacy automatic coordinator loop orchestration gate-create Create a decision gate blocking a task orchestration gate-resolve Resolve a pending decision gate orchestration gate-list List decision gates @@ -419,6 +430,9 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string { if (command === 'linear list-issues' && flag === 'cursor') { return '--cursor Opaque cursor returned by a previous list-issues page' } + if (command === 'orchestration worker-read' && flag === 'cursor') { + return '--cursor Opaque cursor returned by a previous worker-read page' + } if (command === 'linear list-issues' && flag === 'workspace') { return '--workspace Connected Linear workspace id, or all' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 529c8c78a6a..0725fcff419 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -353,6 +353,15 @@ describe('orca root help', () => { expect(logSpy.mock.calls[0][0]).toContain( 'orca terminal create --worktree active --command "codex"' ) + expect(logSpy.mock.calls[0][0]).toContain( + 'orchestration worker-start Start a supervised worker locally or on a connected Orca server' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'orchestration ask Ask the coordinator a blocking question' + ) + expect(logSpy.mock.calls[0][0]).toContain( + 'orchestration worker-abandon Fence an uncertain worker without claiming it stopped' + ) expect(callMock).not.toHaveBeenCalled() }) @@ -408,6 +417,20 @@ describe('orca root help', () => { expect(callMock).not.toHaveBeenCalled() }) + it('describes worker-read cursors as opaque', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + logSpy.mockClear() + + await main(['orchestration', 'worker-read', '--help'], '/tmp/repo') + + const help = String(logSpy.mock.calls[0][0]) + expect(help).toContain( + '--cursor Opaque cursor returned by a previous worker-read page' + ) + expect(help).not.toContain('Line cursor from a previous read') + expect(callMock).not.toHaveBeenCalled() + }) + it('advertises Linear issue linking on worktree create and set help', async () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) logSpy.mockClear() @@ -501,6 +524,7 @@ describe('orca root help', () => { describe('orca cli worktree awareness', () => { const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE const originalUserDataPath = process.env.ORCA_USER_DATA_PATH + const originalDevCliInvocation = process.env.ORCA_DEV_CLI_INVOCATION const originalPairingCode = process.env.ORCA_PAIRING_CODE const originalRemotePairing = process.env.ORCA_REMOTE_PAIRING const originalEnvironment = process.env.ORCA_ENVIRONMENT @@ -511,6 +535,7 @@ describe('orca cli worktree awareness', () => { callMock.mockReset() delete process.env.ORCA_TERMINAL_HANDLE delete process.env.ORCA_USER_DATA_PATH + delete process.env.ORCA_DEV_CLI_INVOCATION delete process.env.ORCA_WORKSPACE_ID delete process.env.ORCA_WORKTREE_ID // Isolate the pane key so claude-teams tests that set it don't leak a @@ -555,6 +580,11 @@ describe('orca cli worktree awareness', () => { } else { process.env.ORCA_USER_DATA_PATH = originalUserDataPath } + if (originalDevCliInvocation === undefined) { + delete process.env.ORCA_DEV_CLI_INVOCATION + } else { + process.env.ORCA_DEV_CLI_INVOCATION = originalDevCliInvocation + } if (originalPairingCode === undefined) { delete process.env.ORCA_PAIRING_CODE } else { @@ -3489,17 +3519,11 @@ describe('orca cli worktree awareness', () => { expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients') }) - it('passes all reset scope explicitly for no-flag orchestration reset', async () => { - callMock.mockResolvedValueOnce(okFixture('req_reset', { reset: 'all' })) - vi.spyOn(console, 'log').mockImplementation(() => {}) - + it('rejects no-flag orchestration reset before calling the runtime', async () => { await main(['orchestration', 'reset'], '/tmp/repo') - expect(callMock).toHaveBeenCalledWith('orchestration.reset', { - all: true, - tasks: undefined, - messages: undefined - }) + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) }) it.each([ @@ -3517,16 +3541,6 @@ describe('orca cli worktree awareness', () => { args: ['orchestration', 'reset', '--messages'], params: { all: undefined, tasks: undefined, messages: true }, reset: 'messages' - }, - { - args: ['orchestration', 'reset', '--tasks', '--messages'], - params: { all: undefined, tasks: true, messages: true }, - reset: 'tasks' - }, - { - args: ['orchestration', 'reset', '--all', '--tasks'], - params: { all: true, tasks: true, messages: undefined }, - reset: 'all' } ])('passes explicit reset flags through for $args', async ({ args, params, reset }) => { callMock.mockResolvedValueOnce(okFixture('req_reset', { reset })) @@ -3537,6 +3551,16 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('orchestration.reset', params) }) + it.each([ + ['orchestration', 'reset', '--tasks', '--messages'], + ['orchestration', 'reset', '--all', '--tasks'] + ])('rejects conflicting reset scopes for $args', async (...args) => { + await main(args, '/tmp/repo') + + expect(callMock).not.toHaveBeenCalled() + expect(process.exitCode).toBe(1) + }) + it('rejects unknown task-update status with an enum-aware error', async () => { process.env.ORCA_TERMINAL_HANDLE = 'term_coord' const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -3629,6 +3653,33 @@ describe('orca cli worktree awareness', () => { }) }) + it('passes dev mode from an explicit dev CLI marker with a custom profile path', async () => { + process.env.ORCA_TERMINAL_HANDLE = 'term_sender' + process.env.ORCA_USER_DATA_PATH = '/tmp/federation-acceptance-profile' + process.env.ORCA_DEV_CLI_INVOCATION = '1' + callMock.mockResolvedValueOnce({ + id: 'req_dispatch', + ok: true, + result: { + dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' } + }, + _meta: { + runtimeId: 'runtime-1' + } + }) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_worker', '--inject'], + '/tmp/repo' + ) + + expect(callMock).toHaveBeenCalledWith( + 'orchestration.dispatch', + expect.objectContaining({ devMode: true }) + ) + }) + it('uses the resolved enclosing worktree for terminal consumers', async () => { queueFixtures( callMock, @@ -3733,11 +3784,14 @@ describe('orca cli worktree awareness', () => { host: { totalMemory: 8 * 1024 * 1024, freeMemory: 2 * 1024 * 1024, + availableMemory: 2 * 1024 * 1024, + availableMemorySource: 'free-memory', usedMemory: 6 * 1024 * 1024, memoryUsagePercent: 75, cpuCoreCount: 8, loadAverage1m: 1.25 }, + processMemoryMetric: 'rss', totalCpu: 3.75, totalMemory: 2 * 1024 * 1024, collectedAt: 1000 @@ -3750,6 +3804,8 @@ describe('orca cli worktree awareness', () => { expect(callMock).toHaveBeenCalledWith('diagnostics.memory') const output = logSpy.mock.calls.flat().join('\n') expect(output).toContain('totalMemory: 2.0 MB') + expect(output).toContain('processMemoryMetric: summed RSS; shared or aliased pages may repeat') + expect(output).toContain('hostAvailable: 2.0 MB (free-memory)') expect(output).toContain('app: 1.0 MB') expect(output).toContain('- feature 1.0 MB 2.5% 1 session') }) diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index c11292453c1..c15e9e1e29c 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createServer, type Socket } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../shared/protocol-version' import { RuntimeClient, RuntimeRpcFailureError } from './runtime-client' import { launchOrcaApp } from './runtime/launch' @@ -74,6 +75,87 @@ function findUnusedPid(seed = 200_000): number { // Windows does not support Unix domain sockets in the same way, causing // EACCES errors on listen(), so the suite is skipped on that platform. describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { + it('adds an opaque durable request ID only to orchestration mutations', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const requests: Record[] = [] + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as Record + requests.push(request) + const result = + request.method === 'status.get' + ? { capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] } + : {} + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 500) + await client.call( + 'orchestration.send', + { subject: 'hello' }, + { + orchestrationRequestId: 'mutation_explicit' + } + ) + await client.call('orchestration.taskList', {}) + + expect(requests[0]?.method).toBe('status.get') + expect(requests[1]?.orchestrationRequestId).toBe('mutation_explicit') + expect(requests[1]?.orchestrationContractVersion).toBe(1) + expect(requests[2]?.orchestrationRequestId).toBeUndefined() + }) + + it('rejects an old local runtime before sending an orchestration mutation', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) + const endpoint = join(userDataPath, 'runtime.sock') + const requests: Record[] = [] + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + const request = JSON.parse(String(data).trim()) as Record + requests.push(request) + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { capabilities: [] }, + _meta: { runtimeId: 'runtime-1' } + })}\n` + ) + }) + }) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeMetadata(userDataPath, endpoint) + + const client = new RuntimeClient(userDataPath, 500) + await expect(client.call('orchestration.send', { subject: 'hello' })).rejects.toMatchObject({ + code: 'orchestration_migration_required', + data: { + reason: 'runtime_capability_missing', + effectsApplied: false, + nextCommandArgs: ['skills', 'get', 'orchestration', '--full'] + } + }) + expect(requests).toHaveLength(1) + expect(requests[0]?.method).toBe('status.get') + }) + it('returns the full RPC envelope for successful calls', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 097ec3534bc..bb3cb905701 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -1,4 +1,10 @@ +import { randomUUID } from 'node:crypto' import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' +import { + isOrchestrationMutation, + orchestrationMigrationData +} from '../../shared/orchestration-rpc-contract' import { parsePairingCode, type PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' @@ -10,6 +16,8 @@ import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environme import { describeRuntimeCompatBlock, evaluateRuntimeCompat } from '../../shared/protocol-compat' import { MIN_COMPATIBLE_RUNTIME_SERVER_VERSION, + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_CONTRACT_VERSION, RUNTIME_PROTOCOL_VERSION } from '../../shared/protocol-version' @@ -27,6 +35,7 @@ export class RuntimeClient { private readonly remotePairing: PairingOffer | null private readonly environmentSelector: string | null private remoteCompatChecked = false + private orchestrationContractCheck: Promise | null = null // Why: browser commands trigger first-time session init (agent-browser connect + // CDP proxy setup) which can take 15-30s. 60s accommodates cold start without @@ -50,21 +59,39 @@ export class RuntimeClient { async call( method: string, params?: unknown, - options?: { - timeoutMs?: number - } + options?: { timeoutMs?: number } & RuntimeOrchestrationEnvelope ): Promise> { const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params) + const orchestrationMutation = isOrchestrationMutation(method, params) + if (orchestrationMutation) { + await this.ensureOrchestrationContractCompatible(effectiveTimeoutMs) + } + const orchestrationRequestId = orchestrationMutation + ? (options?.orchestrationRequestId ?? randomUUID()) + : undefined + const envelope = { + orchestrationCapability: options?.orchestrationCapability, + orchestrationContractVersion: method.startsWith('orchestration.') + ? ORCHESTRATION_CONTRACT_VERSION + : undefined, + orchestrationRequestId + } if (this.remotePairing) { if (method !== 'status.get') { await this.ensureRemoteRuntimeCompatible(effectiveTimeoutMs) } - const response = await sendWebSocketRequest( - this.remotePairing, - method, - params, - effectiveTimeoutMs - ) + let response + try { + response = await sendWebSocketRequest( + this.remotePairing, + method, + params, + effectiveTimeoutMs, + envelope + ) + } catch (error) { + throw attachMutationRecovery(error, orchestrationRequestId) + } if (response.ok === false) { throw new RuntimeRpcFailureError(response) } @@ -76,7 +103,12 @@ export class RuntimeClient { return response } const metadata = readMetadata(this.userDataPath) - const response = await sendRequest(metadata, method, params, effectiveTimeoutMs) + let response + try { + response = await sendRequest(metadata, method, params, effectiveTimeoutMs, envelope) + } catch (error) { + throw attachMutationRecovery(error, orchestrationRequestId) + } if (response.ok === false) { throw new RuntimeRpcFailureError(response) } @@ -165,6 +197,28 @@ export class RuntimeClient { } } + private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise { + if (!this.orchestrationContractCheck) { + this.orchestrationContractCheck = this.checkOrchestrationContractCompatibility(timeoutMs) + } + await this.orchestrationContractCheck + } + + private async checkOrchestrationContractCompatibility(timeoutMs: number): Promise { + const response = await this.call('status.get', undefined, { timeoutMs }) + if (this.remotePairing) { + this.assertRemoteRuntimeStatusCompatible(response.result) + this.remoteCompatChecked = true + } + if (!response.result.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'orchestration_migration_required', + 'The connected Orca runtime does not support the current orchestration contract. No effects were applied.', + orchestrationMigrationData('runtime_capability_missing') + ) + } + } + private assertRemoteRuntimeStatusCompatible(status: RuntimeStatus): void { const verdict = evaluateRuntimeCompat({ clientProtocolVersion: RUNTIME_PROTOCOL_VERSION, @@ -213,6 +267,20 @@ export class RuntimeClient { } } +function attachMutationRecovery(error: unknown, requestId: string | undefined): unknown { + if (!requestId || !(error instanceof RuntimeClientError)) { + return error + } + return new RuntimeClientError( + error.code, + `${error.message} Orchestration mutation request ID: ${requestId}.`, + { + ...(error.data && typeof error.data === 'object' ? error.data : {}), + orchestrationRequestId: requestId + } + ) +} + function throwDesktopActivationBlocked(): never { throw new RuntimeClientError( 'desktop_activation_blocked', diff --git a/src/cli/runtime/transport.ts b/src/cli/runtime/transport.ts index 18385cb1ae7..fc6c3339ded 100644 --- a/src/cli/runtime/transport.ts +++ b/src/cli/runtime/transport.ts @@ -1,6 +1,7 @@ import { createConnection } from 'node:net' import { randomUUID } from 'node:crypto' import { findTransport, type RuntimeMetadata } from '../../shared/runtime-bootstrap' +import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { isKeepaliveFrame, RuntimeRpcEnvelopeSchema } from './envelope-schema' import { RuntimeClientError, type RuntimeRpcResponse } from './types' import { MAX_TIMER_DELAY_MS, isSafeTimerDelayMs } from '../../shared/timer-delay' @@ -9,7 +10,8 @@ export async function sendRequest( metadata: RuntimeMetadata, method: string, params: unknown, - timeoutMs: number + timeoutMs: number, + envelope?: RuntimeOrchestrationEnvelope ): Promise> { if (!isSafeTimerDelayMs(timeoutMs)) { throw new RuntimeClientError( @@ -183,7 +185,10 @@ export async function sendRequest( id: requestId, authToken: metadata.authToken, method, - params + params, + orchestrationCapability: envelope?.orchestrationCapability, + orchestrationContractVersion: envelope?.orchestrationContractVersion, + orchestrationRequestId: envelope?.orchestrationRequestId })}\n` ) }) diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 530b20f1363..306bbe0e3d1 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -18,7 +18,8 @@ import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' import { MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, - RUNTIME_PROTOCOL_VERSION + RUNTIME_PROTOCOL_VERSION, + SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../shared/protocol-version' vi.mock('./launch', () => ({ @@ -29,6 +30,7 @@ type TestRuntime = { endpoint: string publicKeyB64: string deviceToken: string + authFrames: Record[] close: () => Promise } @@ -55,6 +57,11 @@ describe('CLI remote WebSocket transport', () => { expect(response.ok).toBe(true) expect(response.result.runtimeId).toBe('runtime-ws-1') + expect(runtime.authFrames).toContainEqual( + expect.objectContaining({ + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }) + ) }) it('rejects malformed remote pairing codes before local runtime lookup', () => { @@ -162,6 +169,29 @@ describe('CLI remote WebSocket transport', () => { message: expect.stringContaining('server is too old') }) }) + + it('blocks orchestration mutations when a remote runtime lacks the contract capability', async () => { + const runtime = await startTestRuntime('runtime-old-orchestration', { capabilities: [] }) + servers.push(runtime) + const client = new RuntimeClient( + '/tmp/unused', + 5_000, + encodePairingOffer({ + v: 2, + endpoint: runtime.endpoint, + deviceToken: runtime.deviceToken, + publicKeyB64: runtime.publicKeyB64 + }) + ) + + await expect(client.call('orchestration.send', { subject: 'hello' })).rejects.toMatchObject({ + code: 'orchestration_migration_required', + data: { + reason: 'runtime_capability_missing', + effectsApplied: false + } + }) + }) }) async function startTestRuntime( @@ -183,6 +213,7 @@ async function startTestRuntime( const deviceToken = `token-${runtimeId}` const httpServer = createServer() const wss = new WebSocketServer({ server: httpServer }) + const authFrames: Record[] = [] wss.on('connection', (ws) => { let sharedKey: Uint8Array | null = null @@ -191,7 +222,10 @@ async function startTestRuntime( ws.on('message', (data) => { const frame = data.toString() if (!sharedKey) { - const hello = JSON.parse(frame) as { type?: string; publicKeyB64?: string } + const hello = JSON.parse(frame) as Record & { + type?: string + publicKeyB64?: string + } const clientPublicKey = Buffer.from(hello.publicKeyB64 ?? '', 'base64') sharedKey = deriveSharedKey(serverKeyPair.secretKey, clientPublicKey) ws.send(JSON.stringify({ type: 'e2ee_ready' })) @@ -204,7 +238,11 @@ async function startTestRuntime( return } if (!authenticated) { - const auth = JSON.parse(plaintext) as { type?: string; deviceToken?: string } + const auth = JSON.parse(plaintext) as Record & { + type?: string + deviceToken?: string + } + authFrames.push(auth) if (auth.type !== 'e2ee_auth' || auth.deviceToken !== deviceToken) { ws.send(encrypt(JSON.stringify({ type: 'e2ee_error' }), sharedKey)) ws.close(4001, 'auth failed') @@ -260,6 +298,7 @@ async function startTestRuntime( endpoint: `ws://127.0.0.1:${address.port}`, publicKeyB64: publicKeyToBase64(serverKeyPair.publicKey), deviceToken, + authFrames, close: async () => { await new Promise((resolve) => { wss.close(() => resolve()) diff --git a/src/cli/runtime/websocket-transport.ts b/src/cli/runtime/websocket-transport.ts index ac5fc677919..e53fbd670bd 100644 --- a/src/cli/runtime/websocket-transport.ts +++ b/src/cli/runtime/websocket-transport.ts @@ -1,4 +1,5 @@ import type { PairingOffer } from '../../shared/pairing' +import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' import { RemoteRuntimeClientError, sendRemoteRuntimeRequest @@ -9,10 +10,11 @@ export async function sendWebSocketRequest( pairing: PairingOffer, method: string, params: unknown, - timeoutMs: number + timeoutMs: number, + envelope?: RuntimeOrchestrationEnvelope ): Promise> { try { - return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs) + return await sendRemoteRuntimeRequest(pairing, method, params, timeoutMs, envelope) } catch (error) { if (error instanceof RemoteRuntimeClientError) { throw new RuntimeClientError(error.code, error.message) diff --git a/src/cli/specs/orchestration-worker-specs.ts b/src/cli/specs/orchestration-worker-specs.ts new file mode 100644 index 00000000000..2b05853e058 --- /dev/null +++ b/src/cli/specs/orchestration-worker-specs.ts @@ -0,0 +1,71 @@ +import { GLOBAL_FLAGS, type CommandSpec } from '../args' + +export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['orchestration', 'worker-start'], + summary: 'Start one supervised worker on the Run home or a connected Orca server', + usage: + 'orca orchestration worker-start --task [--on ] [--worktree ] (--agent | --terminal ) [--name ] [--repo ] [--base-branch ] [--display-name ] [--comment ] [--setup ] [--retry-of ] [--timeout-ms ] [--run ] [--from ] [--retry-request ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'task', + 'on', + 'worktree', + 'name', + 'repo', + 'base-branch', + 'display-name', + 'comment', + 'setup', + 'agent', + 'terminal', + 'retry-of', + 'timeout-ms', + 'run', + 'from', + 'retry-request' + ], + notes: [ + 'Current and existing worktrees never rerun setup; a fresh agent terminal is created unless --terminal is explicit.', + 'New worktrees use agent-first creation and default --setup to run. Repository start-immediately runs setup beside the agent; wait-for-setup gates agent readiness and task input.', + 'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.', + '--on selects only the worker server; the Run and this command remain on the current Orca server.', + 'Remote current and new-child are invalid; discover an exact remote selector or use new-top-level.', + '--retry-of links the replacement attempt but does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.', + 'The call exits 0 only for ready. Failed or outcome_unknown exits 1 and JSON includes stage/failedStage, setup, effects, residualResources, and recovery commands when needed.' + ] + }, + { + path: ['orchestration', 'worker-show'], + summary: 'Inspect one supervised worker Dispatch', + usage: 'orca orchestration worker-show --dispatch [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch'] + }, + { + path: ['orchestration', 'worker-read'], + summary: 'Read bounded output from one supervised worker', + usage: + 'orca orchestration worker-read --dispatch [--source ] [--cursor ] [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'source', 'cursor', 'limit'], + notes: [ + 'The default auto source uses an exact hook-reported transcript when available and otherwise returns labeled terminal output.', + 'A returned cursor is pinned to the exact source; start a fresh read if Orca reports source_changed.' + ] + }, + { + path: ['orchestration', 'worker-stop'], + summary: 'Fence and stop only one supervised agent terminal', + usage: + 'orca orchestration worker-stop --dispatch [--retry-request ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], + notes: ['Never deletes the worktree, setup terminal, configured tabs, or unrelated processes.'] + }, + { + path: ['orchestration', 'worker-abandon'], + summary: 'Fence a worker without claiming its process stopped', + usage: + 'orca orchestration worker-abandon --dispatch [--retry-request ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'dispatch', 'retry-request'], + notes: ['Retains all possibly-live resources and performs no process or filesystem action.'] + } +] diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index bb63f261d00..226f9dcb7f4 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -1,15 +1,51 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' +import { ORCHESTRATION_WORKER_COMMAND_SPECS } from './orchestration-worker-specs' export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['orchestration', 'run-create'], + summary: 'Create and bind a lightweight orchestration Run', + usage: 'orca orchestration run-create --objective [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'objective', 'from', 'retry-request'], + notes: [ + 'A Run is a namespace and home inbox. It never schedules or places workers.', + '--retry-request is only for exact recovery after an unknown mutation result.' + ] + }, + { + path: ['orchestration', 'run-use'], + summary: 'Bind this coordinator terminal to an existing Run', + usage: 'orca orchestration run-use --id [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'from', 'retry-request'] + }, + { + path: ['orchestration', 'run-current'], + summary: 'Show the Run bound to this coordinator terminal', + usage: 'orca orchestration run-current [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'from'] + }, + { + path: ['orchestration', 'run-list'], + summary: 'List lightweight orchestration Runs', + usage: 'orca orchestration run-list [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['orchestration', 'run-show'], + summary: 'Show one lightweight orchestration Run', + usage: 'orca orchestration run-show --id [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id'] + }, { path: ['orchestration', 'send'], summary: 'Send an inter-agent message', usage: - 'orca orchestration send --to --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--task-id ] [--dispatch-id ] [--files-modified ] [--report-path ] [--phase ] [--json]', + 'orca orchestration send --subject [--to ] [--run ] [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--task-id ] [--dispatch-id ] [--outcome ] [--files-modified ] [--report-path ] [--phase ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'to', + 'run', 'from', 'subject', 'body', @@ -19,13 +55,19 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'payload', 'task-id', 'dispatch-id', + 'dispatch-capability', + 'retry-request', + 'outcome', 'files-modified', 'report-path', 'phase' ], notes: [ 'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:".', - 'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.', + "worker_done and heartbeat are exact-Dispatch signals and cannot target groups; omit --to to use the Dispatch's Run mailbox.", + 'worker_done requires --outcome succeeded or --outcome failed.', + 'From an active Dispatch, an omitted recipient defaults to its owning Run mailbox.', + 'Use --to dispatch: for attempt-specific coordinator guidance; Orca durably relays it to a connected worker server.', 'A worker_done with the active task/dispatch IDs completes that task only from the dispatched pane. When stable pane identity is unavailable, the sender handle must exactly match the dispatch assignee; injected preambles include the correct --from value.', 'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.' ] @@ -34,8 +76,9 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'check'], summary: 'Check messages for a terminal', usage: - 'orca orchestration check [--terminal ] [--unread | --peek | --all] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]\n' + - ' --unread (default): return only unread messages and mark them read.\n' + + 'orca orchestration check [--terminal ] [--run ] [--ack ] [--unread | --peek | --all] [--types ] [--format] [--wait] [--timeout-ms ] [--json]\n' + + " default: return the bound Run's oldest unacknowledged FIFO batch.\n" + + ' --ack: acknowledge the prior whole batch before checking/waiting.\n' + ' --peek: return only unread messages without marking them read.\n' + ' --all: return every message for the handle; does not mark read.\n' + ' --wait: block until a matching message arrives or --timeout-ms expires.\n' + @@ -46,23 +89,29 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ allowedFlags: [ ...GLOBAL_FLAGS, 'terminal', + 'run', + 'ack', 'unread', 'peek', 'all', 'types', - 'inject', + 'format', 'wait', - 'timeout-ms' + 'timeout-ms', + 'retry-request' ], notes: [ - 'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".' + 'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".', + '--format renders the returned rows as local text only; it never writes to another terminal.', + 'A bound Run replays the same Delivery until --ack; process every message before acknowledging.' ] }, { path: ['orchestration', 'reply'], summary: 'Reply to a message', - usage: 'orca orchestration reply --id --body [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'from'] + usage: + 'orca orchestration reply --id --body [--run ] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'run', 'from', 'retry-request'] }, { path: ['orchestration', 'inbox'], @@ -74,30 +123,52 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'task-create'], summary: 'Create an orchestration task', usage: - 'orca orchestration task-create --spec [--task-title ] [--display-name ] [--deps ] [--parent ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'spec', 'task-title', 'display-name', 'deps', 'parent'] + 'orca orchestration task-create --spec [--task-title ] [--display-name ] [--deps ] [--parent ] [--run ] [--from ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'spec', + 'task-title', + 'display-name', + 'deps', + 'parent', + 'run', + 'from', + 'retry-request' + ] }, { path: ['orchestration', 'task-list'], summary: 'List orchestration tasks', - usage: 'orca orchestration task-list [--status ] [--ready] [--brief] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief'], + usage: + 'orca orchestration task-list [--status ] [--ready] [--brief] [--run ] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief', 'run', 'from'], notes: ['--brief collapses whitespace and caps each spec at 160 characters.'] }, { path: ['orchestration', 'task-update'], summary: 'Update a task status', usage: - 'orca orchestration task-update --id --status [--result ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'], + 'orca orchestration task-update --id --status [--result ] [--run ] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result', 'run', 'from', 'retry-request'], notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.'] }, + ...ORCHESTRATION_WORKER_COMMAND_SPECS, { path: ['orchestration', 'dispatch'], summary: 'Dispatch a task to a terminal', usage: - 'orca orchestration dispatch --task --to [--from ] [--inject] [--dry-run] [--return-preamble] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject', 'dry-run', 'return-preamble'] + 'orca orchestration dispatch --task --to [--from ] [--run ] [--inject] [--dry-run] [--return-preamble] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'task', + 'to', + 'from', + 'run', + 'inject', + 'dry-run', + 'return-preamble', + 'retry-request' + ] }, { path: ['orchestration', 'dispatch-show'], @@ -110,14 +181,30 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ path: ['orchestration', 'ask'], summary: 'Ask the coordinator a question and block until answered', usage: - 'orca orchestration ask --to --question [--options ] [--timeout-ms ] [--from ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'to', 'question', 'options', 'timeout-ms', 'from'] + 'orca orchestration ask (--question | --resume ) [--to ] [--run ] [--options ] [--timeout-ms ] [--from ] [--json]', + allowedFlags: [ + ...GLOBAL_FLAGS, + 'to', + 'run', + 'question', + 'resume', + 'dispatch-capability', + 'options', + 'timeout-ms', + 'from', + 'retry-request' + ], + notes: [ + 'From an active Dispatch, a new question defaults to its owning Run mailbox.', + 'Timeout leaves the question pending; resume with the original message ID.' + ] }, { - path: ['orchestration', 'run'], - summary: 'Start the coordinator loop', + path: ['orchestration', 'coordinator-start'], + aliases: [['orchestration', 'run']], + summary: 'Retired: load the current orchestration skill', usage: - 'orca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]', + 'orca orchestration coordinator-start --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--json]', allowedFlags: [ ...GLOBAL_FLAGS, 'spec', @@ -125,26 +212,34 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ 'poll-interval-ms', 'max-concurrent', 'worktree' + ], + notes: [ + 'This command performs no effects and returns the exact `skills get orchestration --full` recovery action.', + 'Use the lightweight Run, Task, and worker-start primitives described by the current skill.' ] }, { - path: ['orchestration', 'run-stop'], - summary: 'Stop the active coordinator run', - usage: 'orca orchestration run-stop [--json]', - allowedFlags: [...GLOBAL_FLAGS] + path: ['orchestration', 'coordinator-stop'], + aliases: [['orchestration', 'run-stop']], + summary: 'Retired: load the current orchestration skill', + usage: 'orca orchestration coordinator-stop [--json]', + allowedFlags: [...GLOBAL_FLAGS], + notes: [ + 'This command performs no effects and returns the exact `skills get orchestration --full` recovery action.' + ] }, { path: ['orchestration', 'gate-create'], summary: 'Create a decision gate blocking a task', usage: 'orca orchestration gate-create --task --question [--options ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options'] + allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options', 'retry-request'] }, { path: ['orchestration', 'gate-resolve'], summary: 'Resolve a pending decision gate', usage: 'orca orchestration gate-resolve --id --resolution [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution'] + allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution', 'retry-request'] }, { path: ['orchestration', 'gate-list'], @@ -154,8 +249,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ }, { path: ['orchestration', 'reset'], - summary: 'Reset orchestration state (one scope; bare command resets all)', - usage: 'orca orchestration reset [--all | --tasks | --messages] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages'] + summary: 'Reset one explicit orchestration state scope', + usage: 'orca orchestration reset (--all | --tasks | --messages) [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages', 'retry-request'] } ] diff --git a/src/cli/specs/project.ts b/src/cli/specs/project.ts index 989a83c9f77..caeabda3de7 100644 --- a/src/cli/specs/project.ts +++ b/src/cli/specs/project.ts @@ -27,7 +27,10 @@ export const PROJECT_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca project setup-existing-folder --project --host --path [--kind git|folder] [--display-name ] [--json]', allowedFlags: [...GLOBAL_FLAGS, 'project', 'host', 'path', 'kind', 'display-name'], - notes: ['For remote runtimes, --path must be an absolute path on the remote server.'], + notes: [ + 'For remote runtimes, --path must be an absolute path on the remote server.', + 'SSH targets are set up through the desktop UI because the desktop client owns SSH connections.' + ], examples: [ 'orca project setup-existing-folder --project github:stablyai/orca --host local --path ~/orca', 'orca project setup-existing-folder --project github:stablyai/orca --host runtime:gpu --path /home/me/orca --kind git --json' diff --git a/src/cli/workspace-format.ts b/src/cli/workspace-format.ts index 7be9ba302f0..613e6f1ee7e 100644 --- a/src/cli/workspace-format.ts +++ b/src/cli/workspace-format.ts @@ -14,15 +14,19 @@ import type { MemorySnapshot, WorktreeMemory } from '../shared/types' export function formatMemorySnapshot(snapshot: MemorySnapshot): string { const topWorktrees = [...snapshot.worktrees].sort((a, b) => b.memory - a.memory).slice(0, 10) + const hostAvailable = snapshot.host.availableMemory ?? snapshot.host.freeMemory + const hostAvailableSource = snapshot.host.availableMemorySource ?? 'free-memory' const lines = [ `collectedAt: ${new Date(snapshot.collectedAt).toISOString()}`, `totalMemory: ${formatByteCount(snapshot.totalMemory)}`, + `processMemoryMetric: ${formatProcessMemoryMetric(snapshot.processMemoryMetric)}`, `totalCpu: ${formatCpu(snapshot.totalCpu)}`, [ `hostUsed: ${formatByteCount(snapshot.host.usedMemory)}`, `/ ${formatByteCount(snapshot.host.totalMemory)}`, `(${snapshot.host.memoryUsagePercent.toFixed(1)}%)` ].join(' '), + [`hostAvailable: ${formatByteCount(hostAvailable)}`, `(${hostAvailableSource})`].join(' '), [ `app: ${formatByteCount(snapshot.app.memory)}`, `(main ${formatByteCount(snapshot.app.main.memory)},`, @@ -60,6 +64,12 @@ function formatCpu(cpu: number): string { return `${cpu.toFixed(1)}%` } +function formatProcessMemoryMetric(metric: MemorySnapshot['processMemoryMetric']): string { + return metric === 'working-set' + ? 'summed working set; shared pages may repeat' + : 'summed RSS; shared or aliased pages may repeat' +} + function formatByteCount(bytes: number): string { if (!Number.isFinite(bytes) || bytes <= 0) { return '0 B' diff --git a/src/main/agent-hooks/agent-status-pane-index.ts b/src/main/agent-hooks/agent-status-pane-index.ts new file mode 100644 index 00000000000..3844ef42127 --- /dev/null +++ b/src/main/agent-hooks/agent-status-pane-index.ts @@ -0,0 +1,22 @@ +import type { AgentStatusIpcPayload } from '../../shared/agent-status-types' + +/** Groups one agent-status snapshot by pane key so a projection that resolves many + * panes reads the snapshot once instead of rescanning (and re-materializing) it per + * pane. Rows without a pane key are unaddressable here and dropped. */ +export function indexAgentStatusRowsByPaneKey( + rows: readonly AgentStatusIpcPayload[] +): Map { + const byPaneKey = new Map() + for (const row of rows) { + if (!row.paneKey) { + continue + } + const existing = byPaneKey.get(row.paneKey) + if (existing) { + existing.push(row) + continue + } + byPaneKey.set(row.paneKey, [row]) + } + return byPaneKey +} diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.test.ts b/src/main/agent-hooks/hook-provider-session-invalidation.test.ts new file mode 100644 index 00000000000..15338c20056 --- /dev/null +++ b/src/main/agent-hooks/hook-provider-session-invalidation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { createHookProviderSessionInvalidator } from './hook-provider-session-invalidation' + +describe('createHookProviderSessionInvalidator', () => { + it('names the worktree the first time a pane reports a provider session', () => { + const collect = createHookProviderSessionInvalidator() + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }])).toEqual(['w1']) + }) + + it('stays quiet while the same session keeps being reported', () => { + const collect = createHookProviderSessionInvalidator() + const rows = [{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }] + collect(rows) + + expect(collect(rows)).toEqual([]) + }) + + it('names the worktree when a pane relaunches under a new session', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2', worktreeId: 'w1' }])).toEqual(['w1']) + }) + + it('names the worktree when a pane loses its session entirely', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([])).toEqual(['w1']) + }) + + it('names both worktrees when a pane moves without changing session', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w2' }])).toEqual([ + 'w1', + 'w2' + ]) + }) + + it('invalidates when Pi keeps its session id but changes transcript path', () => { + const collect = createHookProviderSessionInvalidator() + collect([ + { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/a.jsonl', worktreeId: 'w1' } + ]) + + expect( + collect([ + { paneKey: 'tab:leaf', sessionId: 's1', transcriptPath: '/pi/b.jsonl', worktreeId: 'w1' } + ]) + ).toEqual(['w1']) + }) + + it('retains the known worktree when a later hook omits it', () => { + const collect = createHookProviderSessionInvalidator() + collect([{ paneKey: 'tab:leaf', sessionId: 's1', worktreeId: 'w1' }]) + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's2' }])).toEqual(['w1']) + }) + + it('ignores a session with no worktree to invalidate', () => { + const collect = createHookProviderSessionInvalidator() + + expect(collect([{ paneKey: 'tab:leaf', sessionId: 's1' }])).toEqual([]) + }) +}) diff --git a/src/main/agent-hooks/hook-provider-session-invalidation.ts b/src/main/agent-hooks/hook-provider-session-invalidation.ts new file mode 100644 index 00000000000..6ef1e6f7d63 --- /dev/null +++ b/src/main/agent-hooks/hook-provider-session-invalidation.ts @@ -0,0 +1,43 @@ +import type { AgentHookProviderSessionIdentity } from './server' + +type KnownSession = { sessionId: string; transcriptPath?: string; worktreeId: string } + +/** Names worktrees whose hook-reported resume identity changed. */ +export function createHookProviderSessionInvalidator(): ( + identities: readonly AgentHookProviderSessionIdentity[] +) => string[] { + let known = new Map() + return (identities) => { + const next = new Map() + const changedWorktrees = new Set() + for (const identity of identities) { + const previous = known.get(identity.paneKey) + const worktreeId = identity.worktreeId ?? previous?.worktreeId + if (!worktreeId) { + continue + } + next.set(identity.paneKey, { + sessionId: identity.sessionId, + ...(identity.transcriptPath ? { transcriptPath: identity.transcriptPath } : {}), + worktreeId + }) + if ( + previous?.sessionId !== identity.sessionId || + previous?.transcriptPath !== identity.transcriptPath || + previous?.worktreeId !== worktreeId + ) { + if (previous?.worktreeId !== worktreeId) { + changedWorktrees.add(previous?.worktreeId ?? worktreeId) + } + changedWorktrees.add(worktreeId) + } + } + for (const [paneKey, previous] of known) { + if (!next.has(paneKey)) { + changedWorktrees.add(previous.worktreeId) + } + } + known = next + return [...changedWorktrees] + } +} diff --git a/src/main/agent-hooks/hook-stdin-contract.ts b/src/main/agent-hooks/hook-stdin-contract.ts index 902ec543287..6c01f9756c2 100644 --- a/src/main/agent-hooks/hook-stdin-contract.ts +++ b/src/main/agent-hooks/hook-stdin-contract.ts @@ -1,6 +1,11 @@ export type PosixHookEmptyPayloadPolicy = 'exit' | 'empty-object' -export const POSIX_HOOK_STDIN_DRAIN_COMMAND = 'cat >/dev/null 2>&1 || :' +// Why: a stripped PATH must not stop a hook from consuming stdin, or the agent +// sees exit 127 and a broken pipe mid-write (#8110). `command -p` resolves from +// the shell's built-in default PATH, so it also survives hosts without /bin/cat +// (NixOS) and ignores a worktree-local `cat` that could capture the payload. +export const POSIX_HOOK_STDIN_READER = '{ command -p cat 2>/dev/null || cat; }' +export const POSIX_HOOK_STDIN_DRAIN_COMMAND = `${POSIX_HOOK_STDIN_READER} >/dev/null 2>&1 || :` // Why: every POSIX hook must own stdin before any no-op exit; sharing this // prelude prevents agent templates from inventing different drain semantics. @@ -9,7 +14,12 @@ export function buildPosixHookPayloadCapture( ): string[] { const emptyPayloadLines = emptyPayloadPolicy === 'empty-object' ? [" payload='{}'"] : [' exit 0'] - return ['payload=$(cat)', 'if [ -z "$payload" ]; then', ...emptyPayloadLines, 'fi'] + return [ + `payload=$(${POSIX_HOOK_STDIN_READER})`, + 'if [ -z "$payload" ]; then', + ...emptyPayloadLines, + 'fi' + ] } export const WINDOWS_HOOK_STDIN_DRAIN_LABEL = 'orca_agent_hook_drain_stdin' diff --git a/src/main/agent-hooks/installer-utils.test.ts b/src/main/agent-hooks/installer-utils.test.ts index 466792009c7..487a58195e6 100644 --- a/src/main/agent-hooks/installer-utils.test.ts +++ b/src/main/agent-hooks/installer-utils.test.ts @@ -31,6 +31,7 @@ import { writeHooksJson, type HooksConfig } from './installer-utils' +import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from './hook-stdin-contract' let tmpDir: string let configPath: string @@ -357,7 +358,7 @@ describe('wrapPosixHookCommand', () => { it('produces a guarded command that no-ops when the script is missing', () => { const cmd = wrapPosixHookCommand('/does/not/exist.sh') expect(cmd).toBe( - "if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi" + `if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then /bin/sh '/does/not/exist.sh'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` ) }) @@ -375,7 +376,7 @@ describe('wrapPosixHookCommand', () => { // /bin/sh as a single argument. const cmd = wrapPosixHookCommand("/path/with'quote/x.sh") expect(cmd).toBe( - "if [ -f '/path/with'\\''quote/x.sh' ] && [ -r '/path/with'\\''quote/x.sh' ] && [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; else cat >/dev/null 2>&1 || :; fi" + `if [ -f '/path/with'\\''quote/x.sh' ] && [ -r '/path/with'\\''quote/x.sh' ] && [ -x '/path/with'\\''quote/x.sh' ]; then /bin/sh '/path/with'\\''quote/x.sh'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` ) }) @@ -384,7 +385,7 @@ describe('wrapPosixHookCommand', () => { ORCA_COPILOT_HOOK_EVENT: 'UserPromptSubmit' }) expect(cmd).toBe( - "if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; else cat >/dev/null 2>&1 || :; fi" + `if [ -f '/does/not/exist.sh' ] && [ -r '/does/not/exist.sh' ] && [ -x '/does/not/exist.sh' ]; then ORCA_COPILOT_HOOK_EVENT='UserPromptSubmit' /bin/sh '/does/not/exist.sh'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` ) }) @@ -558,7 +559,7 @@ describe('wrapWindowsGitBashHookCommand', () => { expect( wrapWindowsGitBashHookCommand('C:\\Users\\alice\\.orca\\agent-hooks\\claude-hook.cmd') ).toBe( - "if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else cat >/dev/null 2>&1 || :; fi" + `if [ -f 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd' ]; then 'C:/Users/alice/.orca/agent-hooks/claude-hook.cmd'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` ) }) diff --git a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts index 0645de342e5..aa303ce31d5 100644 --- a/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts +++ b/src/main/agent-hooks/managed-hook-stdin-lifecycle.test.ts @@ -2,7 +2,7 @@ // matrix catches an unread early exit without duplicating template assertions. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { spawn } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { SFTPWrapper } from 'ssh2' @@ -82,6 +82,7 @@ import { wrapWindowsGitBashHookCommand, wrapWindowsHookCommand } from './installer-utils' +import { POSIX_HOOK_STDIN_READER } from './hook-stdin-contract' import { createAgentHookMemorySftp } from './agent-hook-memory-sftp.test-fixture' const REMOTE_HOME = '/home/dev' @@ -155,6 +156,7 @@ const LOCAL_INSTALLERS = [ type HookRun = { exitCode: number | null stdinErrors: NodeJS.ErrnoException[] + stdout: string } function runHookProcess( @@ -163,8 +165,9 @@ function runHookProcess( env: NodeJS.ProcessEnv ): Promise { return new Promise((resolve, reject) => { - const child = spawn(executable, args, { env, stdio: ['pipe', 'ignore', 'ignore'] }) + const child = spawn(executable, args, { env, stdio: ['pipe', 'pipe', 'ignore'] }) const stdinErrors: NodeJS.ErrnoException[] = [] + let stdout = '' const timeout = setTimeout(() => { child.kill('SIGKILL') reject(new Error('hook did not finish after stdin closed')) @@ -173,10 +176,13 @@ function runHookProcess( clearTimeout(timeout) reject(error) }) + child.stdout.on('data', (chunk: Buffer) => { + stdout += chunk.toString() + }) child.stdin.on('error', (error: NodeJS.ErrnoException) => stdinErrors.push(error)) child.on('close', (exitCode) => { clearTimeout(timeout) - resolve({ exitCode, stdinErrors }) + resolve({ exitCode, stdinErrors, stdout }) }) child.stdin.end(LARGE_PAYLOAD) }) @@ -274,7 +280,9 @@ describe('Windows managed hook stdin structure', () => { copilot.indexOf('if (-not $env:ORCA_AGENT_HOOK_PORT') ) const kimi = readFileSync(join(hooksDir, 'kimi-hook.sh'), 'utf8') - expect(kimi.indexOf('payload=$(cat)')).toBeLessThan(kimi.indexOf('exit 0')) + expect(kimi.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`)).toBeLessThan( + kimi.indexOf('exit 0') + ) } finally { homedirMock.mockImplementation(() => process.env.HOME ?? tmpdir()) if (previousGrokHome === undefined) { @@ -370,7 +378,7 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () it('captures stdin before every possible whole-script success exit', async () => { const scripts = await generatePosixScripts() for (const [agent, script] of scripts) { - const captureIndex = script.indexOf('payload=$(cat)') + const captureIndex = script.indexOf(`payload=$(${POSIX_HOOK_STDIN_READER})`) const firstExitIndex = script.indexOf('exit 0') expect(captureIndex, `${agent} payload capture`).toBeGreaterThanOrEqual(0) expect(firstExitIndex, `${agent} first success exit`).toBeGreaterThan(captureIndex) @@ -393,6 +401,50 @@ describe.skipIf(process.platform === 'win32')('managed hook stdin lifecycle', () } }) + it('does not need PATH to capture or drain POSIX hook stdin', async () => { + const scripts = await generatePosixScripts() + for (const [agent, script] of scripts) { + const result = await runPosixHook(script, { PATH: '' }) + expect(result.exitCode, `${agent} exit code`).toBe(0) + expect(result.stdinErrors, `${agent} stdin errors`).toHaveLength(0) + } + + const missing = await runPosixHook(wrapPosixHookCommand('/missing/orca-hook.sh'), { PATH: '' }) + expect(missing.exitCode, 'missing script launcher exit code').toBe(0) + expect(missing.stdinErrors, 'missing script launcher stdin errors').toHaveLength(0) + }) + + // Why: an unread stdin still exits 0, so exit codes alone cannot prove the + // reader consumed the payload. Assert the captured byte count directly. + it.each([ + ['empty PATH', ''], + // Why: /bin/cat is absent on NixOS-style hosts, so an absolute path alone is + // not enough; the reader must fall back to the shell's default PATH. + ['PATH without coreutils', '/nonexistent'], + // Why: a worktree-local `cat` must never receive the hook payload. + ['PATH whose first cat is a decoy', ''] + ])('captures the whole payload with %s', async (label, pathValue) => { + const decoyDir = mkdtempSync(join(tmpdir(), 'orca-hook-stdin-decoy-')) + try { + let effectivePath = pathValue + if (label === 'PATH whose first cat is a decoy') { + const decoy = join(decoyDir, 'cat') + writeFileSync(decoy, '#!/bin/sh\nexit 0\n', { mode: 0o755 }) + effectivePath = decoyDir + } + const result = await runHookProcess( + '/bin/sh', + ['-c', `payload=$(${POSIX_HOOK_STDIN_READER}); printf '%s' "${'${#payload}'}"`], + { ...hookEnvironment(), PATH: effectivePath } + ) + expect(result.exitCode, `${label} exit code`).toBe(0) + expect(result.stdinErrors, `${label} stdin errors`).toHaveLength(0) + expect(result.stdout, `${label} captured bytes`).toBe(String(LARGE_PAYLOAD.length)) + } finally { + rmSync(decoyDir, { recursive: true, force: true }) + } + }) + it('drains before Claude skips hooks imported by Devin', async () => { const script = (await generatePosixScripts()).get('claude claude-hook.sh') expect(script).toBeDefined() diff --git a/src/main/agent-hooks/server.test.ts b/src/main/agent-hooks/server.test.ts index 6b4e12042c5..e05e0c17312 100644 --- a/src/main/agent-hooks/server.test.ts +++ b/src/main/agent-hooks/server.test.ts @@ -1469,6 +1469,39 @@ describe('AgentHookServer listener replay', () => { ]) }) + it('notifies provider-session subscribers without changing status listener arguments', () => { + const server = new AgentHookServer() + const statuses = vi.fn() + const sessions = vi.fn() + server.subscribeStatusChanges(statuses) + server.subscribeProviderSessionChanges(sessions) + + server.ingestRemote( + { + paneKey: PANE, + worktreeId: 'wt-1', + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + providerSessionOnly: true, + payload: { state: 'done', agentType: 'pi' } + }, + 'conn-1' + ) + + expect(statuses).toHaveBeenCalledWith([]) + expect(sessions).toHaveBeenCalledWith([ + { + paneKey: PANE, + sessionId: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl', + worktreeId: 'wt-1' + } + ]) + }) + it('keeps status-change subscribers when renderer fanout listener is cleared', () => { const server = new AgentHookServer() const statusChangeListener = vi.fn() @@ -7050,6 +7083,38 @@ describe('AgentHookServer ingestRemote', () => { } }) + it('fans a Pi session-only status out to plugins, not just the renderer', () => { + const server = new AgentHookServer() + const rendererListener = vi.fn() + const pluginListener = vi.fn() + server.setListener(rendererListener) + server.subscribeEnrichedStatus(pluginListener) + + server.ingestRemote( + { + paneKey: PANE, + tabId: 'tab-1', + worktreeId: 'wt-1', + providerSession: { + key: 'session_id', + id: 'pi-session-1', + transcriptPath: '/tmp/pi-session-1.jsonl' + }, + providerSessionOnly: true, + payload: { state: 'done', prompt: '', agentType: 'pi' } + }, + 'conn-1' + ) + + // The session-only path returns early, so it must not skip the plugin tap. + expect(rendererListener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) + ) + expect(pluginListener).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: PANE, providerSessionOnly: true }) + ) + }) + it('rejects invalid remote metadata-only session envelopes', () => { const server = new AgentHookServer() const listener = vi.fn() diff --git a/src/main/agent-hooks/server.ts b/src/main/agent-hooks/server.ts index 06dfb151560..a9395cf44de 100644 --- a/src/main/agent-hooks/server.ts +++ b/src/main/agent-hooks/server.ts @@ -84,7 +84,15 @@ export type AgentHookStatusChangeEntry = { observedInCurrentRuntime: boolean } +export type AgentHookProviderSessionIdentity = { + paneKey: string + sessionId: string + transcriptPath?: string + worktreeId?: string +} + type StatusChangeListener = (statuses: AgentHookStatusChangeEntry[]) => void +type ProviderSessionChangeListener = (providerSessions: AgentHookProviderSessionIdentity[]) => void type PaneStatusClearListener = (clear: AgentStatusClearIpcPayload) => void type PaneKeyAliasPersistenceListener = (entries: LegacyPaneKeyAliasEntry[]) => void type PaneKeyAliasEntry = { @@ -456,6 +464,11 @@ export class AgentHookServer { private onClaudeStatusLine: ((event: ClaudeStatusLineRateLimits) => void) | null = null private onPaneStatusCleared: PaneStatusClearListener | null = null private statusChangeListeners = new Set() + private providerSessionChangeListeners = new Set() + // Why: setListener is a single slot owned by the main-window fanout; the + // plugin event bus (and future consumers) need an additive subscription + // that also works in headless serve, where no window listener exists. + private enrichedStatusListeners = new Set<(payload: EnrichedAgentHookEventPayload) => void>() // Why: set via start()'s userDataPath so the class has no direct Electron dependency (mockable in vitest node env). private endpointDir: string | null = null private endpointFilePathCache: string | null = null @@ -509,6 +522,21 @@ export class AgentHookServer { } } + subscribeProviderSessionChanges(listener: ProviderSessionChangeListener): () => void { + this.providerSessionChangeListeners.add(listener) + return () => { + this.providerSessionChangeListeners.delete(listener) + } + } + + /** Multi-subscriber tap on every enriched status change (no replay). */ + subscribeEnrichedStatus(listener: (payload: EnrichedAgentHookEventPayload) => void): () => void { + this.enrichedStatusListeners.add(listener) + return () => { + this.enrichedStatusListeners.delete(listener) + } + } + setPaneStatusClearListener(listener: PaneStatusClearListener | null): void { this.onPaneStatusCleared = listener } @@ -521,6 +549,16 @@ export class AgentHookServer { ) } + /** Provider-session identities, including Pi's metadata-only rows. */ + getProviderSessionIdentities(): AgentHookProviderSessionIdentity[] { + return this.buildStatusChangeNotification().providerSessions + } + + getStatusSnapshotForPane(paneKey: string): AgentStatusIpcPayload[] { + const entry = this.state.lastStatusByPaneKey.get(paneKey) + return entry ? [toAgentStatusIpcPayload(entry as EnrichedAgentHookEventPayload)] : [] + } + inferInterrupt(request: AgentInterruptInferenceRequest): boolean { if (!isValidPaneKey(request.paneKey)) { return false @@ -652,32 +690,57 @@ export class AgentHookServer { } getStatusChangeSnapshot(): AgentHookStatusChangeEntry[] { - return Array.from(this.state.lastStatusByPaneKey.entries()).flatMap(([paneKey, entry]) => { + return this.buildStatusChangeNotification().statuses + } + + private buildStatusChangeNotification(): { + statuses: AgentHookStatusChangeEntry[] + providerSessions: AgentHookProviderSessionIdentity[] + } { + const statuses: AgentHookStatusChangeEntry[] = [] + const providerSessions: AgentHookProviderSessionIdentity[] = [] + for (const [paneKey, entry] of this.state.lastStatusByPaneKey) { const enriched = entry as EnrichedAgentHookEventPayload - return enriched.providerSessionOnly - ? [] - : [ - { - state: enriched.payload.state, - receivedAt: enriched.receivedAt, - observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) - } - ] - }) + if (enriched.providerSession) { + providerSessions.push({ + paneKey, + sessionId: enriched.providerSession.id, + ...(enriched.providerSession.transcriptPath + ? { transcriptPath: enriched.providerSession.transcriptPath } + : {}), + ...(enriched.worktreeId ? { worktreeId: enriched.worktreeId } : {}) + }) + } + if (!enriched.providerSessionOnly) { + statuses.push({ + state: enriched.payload.state, + receivedAt: enriched.receivedAt, + observedInCurrentRuntime: this.runtimeObservedStatusPaneKeys.has(paneKey) + }) + } + } + return { statuses, providerSessions } } private notifyStatusChangeListeners(): void { - if (this.statusChangeListeners.size === 0) { + if (this.statusChangeListeners.size === 0 && this.providerSessionChangeListeners.size === 0) { return } - const snapshot = this.getStatusChangeSnapshot() + const { statuses, providerSessions } = this.buildStatusChangeNotification() for (const listener of this.statusChangeListeners) { try { - listener(snapshot) + listener(statuses) } catch (err) { console.error('[agent-hooks] status-change listener threw', err) } } + for (const listener of this.providerSessionChangeListeners) { + try { + listener(providerSessions) + } catch (err) { + console.error('[agent-hooks] provider-session listener threw', err) + } + } } private markTabClosedForAgentStatus(tabId: string): void { @@ -839,7 +902,7 @@ export class AgentHookServer { this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() - this.onAgentStatus?.(enriched) + this.emitEnrichedStatus(enriched) return enriched } const stateReconciledPayload = @@ -951,10 +1014,23 @@ export class AgentHookServer { this.state.lastStatusByPaneKey.set(enriched.paneKey, enriched) this.scheduleStatusPersist() this.notifyStatusChangeListeners() - this.onAgentStatus?.(enriched) + this.emitEnrichedStatus(enriched) return enriched } + // Why: every status emit must reach plugins too, so a new early-return path + // upstream cannot silently leave the plugin tap behind the main-window fanout. + private emitEnrichedStatus(enriched: EnrichedAgentHookEventPayload): void { + this.onAgentStatus?.(enriched) + for (const listener of this.enrichedStatusListeners) { + try { + listener(enriched) + } catch (err) { + console.error('[agent-hooks] enriched status listener threw', err) + } + } + } + private clearAssistantMessageRetry(paneKey: string): void { const timer = this.assistantMessageRetryTimers.get(paneKey) if (!timer) { diff --git a/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts b/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts new file mode 100644 index 00000000000..b36e7c588be --- /dev/null +++ b/src/main/ai-vault/session-scanner-claude-unicode-scope.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { scanAiVaultSessions } from './session-scanner' +import { resetProjectDirCwdCacheForTests } from './session-scanner-scope-discovery' +import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] + resetProjectDirCwdCacheForTests() +}) + +// Claude's own rule, deliberately spelled out rather than imported from the +// production encoder: these tests exist to catch Orca drifting away from how +// Claude actually names its directories, which a shared helper would hide. +// Claude maps every non-alphanumeric character of the raw cwd to '-', keeping +// case, and records the cwd it was launched with (NFC on macOS). +function claudeProjectDirName(cwd: string): string { + return cwd.normalize('NFC').replace(/[^a-zA-Z0-9]/g, '-') +} + +describe('scanAiVaultSessions — non-ASCII scope paths', () => { + it('lists Claude sessions when the workspace path is NFD and the transcript is NFC', async () => { + // Regression for #10832: macOS hands Orca decomposed (NFD) workspace paths + // while Claude Code writes NFC in both the project dir name and the recorded + // cwd, so every workspace with non-ASCII path segments listed zero sessions. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nfc-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + const workspaceNfc = '/Users/ada/내 드라이브/한국농어촌공사' + const workspaceNfd = workspaceNfc.normalize('NFD') + expect(workspaceNfd).not.toBe(workspaceNfc) + + const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspaceNfc)) + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'korean-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'korean-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: workspaceNfc, + gitBranch: 'main', + message: { role: 'user', content: '안녕하세요' } + } + ]) + ) + + // A newer ASCII session plus limit:1 exhausts the recency cap, so the Korean + // session can only surface through scope discovery — the path under test. + await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true }) + await writeFile( + join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'recent-session', + timestamp: '2026-06-01T10:00:00.000Z', + cwd: '/Users/ada/other', + gitBranch: 'main', + message: { role: 'user', content: 'newer' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'darwin', + limit: 1, + scopePaths: [workspaceNfd] + }) + + expect(result.sessions.map((session) => session.sessionId)).toContain('korean-session') + }) + + it('lists Claude sessions for a Windows workspace whose path has uppercase segments', async () => { + // Claude derives the dir name from the raw cwd, so it keeps 'C--Users-Ada-repo'. + // Encoding from the lowercased comparison key produced 'c--users-ada-repo', + // which never matched — scoped discovery was dead on Windows entirely. + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-win-scope-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + + const workspace = 'C:\\Users\\Ada\\repo' + const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspace)) + await mkdir(projectDir, { recursive: true }) + await writeFile( + join(projectDir, 'windows-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'windows-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: workspace, + gitBranch: 'main', + message: { role: 'user', content: 'hello' } + } + ]) + ) + await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true }) + await writeFile( + join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'), + jsonLines([ + { + type: 'user', + sessionId: 'recent-session', + timestamp: '2026-06-01T10:00:00.000Z', + cwd: '/Users/ada/other', + gitBranch: 'main', + message: { role: 'user', content: 'newer' } + } + ]) + ) + + const result = await scanAiVaultSessions({ + ...roots, + platform: 'win32', + limit: 1, + scopePaths: [workspace] + }) + + expect(result.sessions.map((session) => session.sessionId)).toContain('windows-session') + }) +}) diff --git a/src/main/ai-vault/session-scanner-parse-cache.test.ts b/src/main/ai-vault/session-scanner-parse-cache.test.ts index 6ba5e3266d6..8434f26bac1 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.test.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.test.ts @@ -128,6 +128,45 @@ describe('parseAgentSessionFileCached', () => { expect(incremental?.totalTokens).toBe(420) }) + it('parses an oversized record without quadratic carry copying', async () => { + const root = await makeTempDir() + const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') + // One tool result far larger than a stream chunk. Re-joining the held-over + // partial line per chunk copies O(record^2); the piece list joins once. + const recordBytes = 4 * 1024 * 1024 + await writeFile( + path, + `${[ + userRecord(0, 'question'), + assistantRecord(1, 'x'.repeat(recordBytes)), + assistantRecord(2, 'tail answer') + ].join('\n')}\n` + ) + + const originalConcat = Buffer.concat + let concatenatedBytes = 0 + Buffer.concat = ((list: readonly Uint8Array[], totalLength?: number) => { + const joined = originalConcat(list as Uint8Array[], totalLength) + concatenatedBytes += joined.length + return joined + }) as typeof Buffer.concat + try { + const stats = createSessionParseStats() + const parsed = await parseAgentSessionFileCached( + await claudeCandidate(path), + process.platform, + stats + ) + expect(parsed).not.toBeNull() + } finally { + Buffer.concat = originalConcat + } + + // Linear joins the record about once; the quadratic form copied many times + // that, growing with the square of the record size. + expect(concatenatedBytes).toBeLessThan(recordBytes * 4) + }) + it('shows a trailing unterminated line without double-counting it later', async () => { const root = await makeTempDir() const path = join(root, 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee.jsonl') diff --git a/src/main/ai-vault/session-scanner-parse-cache.ts b/src/main/ai-vault/session-scanner-parse-cache.ts index b7aab770432..b3e4448a410 100644 --- a/src/main/ai-vault/session-scanner-parse-cache.ts +++ b/src/main/ai-vault/session-scanner-parse-cache.ts @@ -311,12 +311,28 @@ async function consumeCompleteJsonlLines(args: { }): Promise { let consumedThrough = args.start let bytesRead = 0 - let remainder: Buffer | null = null + // Why a piece list: re-joining the partial line with every chunk made one + // oversized record (a big tool result) cost O(record^2). Joining once, when a + // newline finally arrives, keeps it linear. + let remainderParts: Buffer[] = [] + let remainderLength = 0 const stream = createReadStream(args.path, { start: args.start }) for await (const chunk of stream as AsyncIterable) { bytesRead += chunk.length - const data = remainder ? Buffer.concat([remainder, chunk]) : chunk + // Why check the chunk alone: the pieces held over are all mid-line, so none + // of them contains a newline. + if (!chunk.includes(NEWLINE_BYTE)) { + remainderParts.push(chunk) + remainderLength += chunk.length + continue + } + const data = + remainderLength > 0 + ? Buffer.concat([...remainderParts, chunk], remainderLength + chunk.length) + : chunk + remainderParts = [] + remainderLength = 0 let lineStart = 0 let newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) while (newlineIndex !== -1) { @@ -329,13 +345,19 @@ async function consumeCompleteJsonlLines(args: { newlineIndex = data.indexOf(NEWLINE_BYTE, lineStart) } consumedThrough += lineStart - // Copy the tail so retaining it doesn't pin the whole chunk buffer. - remainder = lineStart < data.length ? Buffer.from(data.subarray(lineStart)) : null + if (lineStart < data.length) { + // Copy the tail so retaining it doesn't pin the whole chunk buffer. + remainderParts = [Buffer.from(data.subarray(lineStart))] + remainderLength = data.length - lineStart + } } + const trailingPartialLine = + remainderLength > 0 ? Buffer.concat(remainderParts, remainderLength).toString('utf-8') : null + return { consumedThrough, - trailingPartialLine: remainder && remainder.length > 0 ? remainder.toString('utf-8') : null, + trailingPartialLine, bytesRead } } diff --git a/src/main/ai-vault/session-scanner-scope-discovery.ts b/src/main/ai-vault/session-scanner-scope-discovery.ts index 41aac5f2700..b0a4792ffd6 100644 --- a/src/main/ai-vault/session-scanner-scope-discovery.ts +++ b/src/main/ai-vault/session-scanner-scope-discovery.ts @@ -4,7 +4,7 @@ import { createInterface } from 'node:readline' import { extname, join } from 'node:path' import { isPathInsideOrEqual, - normalizeRuntimePathForComparison + normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' import type { AiVaultScanIssue } from '../../shared/ai-vault-types' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -91,7 +91,9 @@ function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set const prefixes = new Set() for (const scopePath of scopePaths) { for (const candidate of scopePathCandidates(scopePath)) { - prefixes.add(encodeClaudeProjectPath(candidate)) + for (const prefix of encodeClaudeProjectPaths(candidate)) { + prefixes.add(prefix) + } } } return prefixes @@ -102,8 +104,22 @@ function scopePathCandidates(scopePath: string): string[] { return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath] } +/** + * Why: Claude derives the directory name from the raw cwd, so encoding from the + * comparison key would lowercase Windows paths and never match on disk. Encode + * the raw path, plus its NFC spelling, since macOS hands us NFD (#10832). + */ +function encodeClaudeProjectPaths(pathValue: string): string[] { + const raw = encodeClaudeProjectPath(pathValue) + const composed = encodeClaudeProjectPath(pathValue.normalize('NFC')) + return raw === composed ? [raw] : [raw, composed] +} + function encodeClaudeProjectPath(pathValue: string): string { - return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-') + const separated = normalizeRuntimePathSeparators(pathValue) + const trimmed = + separated === '/' || /^[A-Za-z]:\/$/.test(separated) ? separated : separated.replace(/\/+$/, '') + return trimmed.replace(/[^a-zA-Z0-9]/g, '-') } function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet) { diff --git a/src/main/ai-vault/session-scanner-source-discovery.ts b/src/main/ai-vault/session-scanner-source-discovery.ts index ee8f6dae2ef..cd369c41496 100644 --- a/src/main/ai-vault/session-scanner-source-discovery.ts +++ b/src/main/ai-vault/session-scanner-source-discovery.ts @@ -21,7 +21,6 @@ const COPILOT_SESSIONS_DIR = join( 'session-state' ) const CURSOR_PROJECTS_DIR = join(homedir(), '.cursor', 'projects') -const GROK_SESSIONS_DIR = resolveGrokSessionsDir() const HERMES_SESSIONS_DIR = join(homedir(), '.hermes', 'sessions') const ROVO_SESSIONS_DIR = join(homedir(), '.rovodev', 'sessions') const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR?.trim() || join(homedir(), '.openclaw') @@ -175,7 +174,9 @@ function grokDiscoveries( limit: number, issues: AiVaultScanIssue[] ): Promise[] { - return sessionRootDirs(options.grokSessionsDir ?? GROK_SESSIONS_DIR, wslHomeDirs, [ + // Resolved lazily: a module-scope call binds across chunks at init time, which + // breaks whenever bundle ordering puts this module before its import. + return sessionRootDirs(options.grokSessionsDir ?? resolveGrokSessionsDir(), wslHomeDirs, [ '.grok', 'sessions' ]).map((rootDir) => diff --git a/src/main/antigravity/hook-service.test.ts b/src/main/antigravity/hook-service.test.ts index fffe15052f9..5861d5e0cc7 100644 --- a/src/main/antigravity/hook-service.test.ts +++ b/src/main/antigravity/hook-service.test.ts @@ -16,6 +16,7 @@ vi.mock('os', async () => { }) import { AntigravityHookService } from './hook-service' +import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' import { createManagedCommandMatcher } from '../agent-hooks/installer-utils' const ANTIGRAVITY_SCRIPT_FILE_NAME = @@ -94,7 +95,7 @@ describe('AntigravityHookService', () => { expect(script).not.toContain('[string]::IsNullOrWhiteSpace($inputData)) { exit 0 }') } else { expect(script).toContain('hook_event_name=${ORCA_ANTIGRAVITY_EVENT}') - expect(script).toContain('payload=$(cat)') + expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`) expect(script).toContain("payload='{}'") expect(script).not.toContain('if [ -z "$payload" ]; then\n exit 0\nfi') // Why: payload is piped to curl via stdin (`payload@-`) so it never lands diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 27663017097..55fae27c93e 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -6,13 +6,25 @@ const { copyFileSyncMock, execFileSyncMock, sessionFromPartitionMock, - dialogShowOpenDialogMock + dialogShowOpenDialogMock, + setPendingCookieImportMock, + clearPendingCookieImportMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), copyFileSyncMock: vi.fn(), execFileSyncMock: vi.fn(), sessionFromPartitionMock: vi.fn(), - dialogShowOpenDialogMock: vi.fn() + dialogShowOpenDialogMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock, + persistUserAgent: vi.fn() + } })) vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) @@ -47,8 +59,16 @@ import { createChromiumCookieTestDatabase, encryptMacChromiumCookie } from './browser-cookie-import-test-database' -import { existsSync, writeFileSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' -import { join } from 'node:path' +import { + existsSync, + mkdirSync, + writeFileSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync +} from 'node:fs' +import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' function chromeBrowser(cookiesPath: string): DetectedBrowser { @@ -382,6 +402,8 @@ describe('importCookiesFromBrowser Chromium', () => { appGetPathMock.mockReset() appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) copyFileSyncMock.mockClear() + setPendingCookieImportMock.mockClear() + clearPendingCookieImportMock.mockClear() execFileSyncMock.mockReset() execFileSyncMock.mockImplementation(() => { throw new Error('OS credential commands are unavailable in this test') @@ -515,20 +537,198 @@ describe('importCookiesFromBrowser Chromium', () => { } }) - it('removes partial staging data when the target database copy fails', async () => { + // Why: #9355 — staging only backs the cold-restart replay, so an AV/EDR handle that blocks + // the staging copy must degrade that fallback, not abort an import the memory path can serve. + it('still imports in-memory when the target database copy fails', async () => { const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') - createChromiumCookieTestDatabase(sourceCookiesPath, []).close() + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() createChromiumCookieTestDatabase(targetCookiesPath, []).close() + // The staging copy runs before the source snapshot, so the first copy is the staging one. copyFileSyncMock.mockImplementationOnce((_source: string, destination: string) => { writeFileSync(destination, 'partial cookie database') - throw new Error('simulated copy failure') + const error = new Error('EBUSY: resource busy or locked, copyfile') as NodeJS.ErrnoException + error.code = 'EBUSY' + throw error }) - const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) - expect(result).toEqual({ ok: false, reason: 'Could not create staging cookie database.' }) - expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + expect(result.ok).toBe(true) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + // The partial staging file is still discarded, so no stale DB replays on cold start. + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — the staged file is also named "Cookies", so the same transient handle can make + // opening it throw. That was fatal too, and the count must stay truthful without a staging DB. + it('still imports in-memory when the staging database cannot be opened', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + mkdirSync(dirname(targetCookiesPath), { recursive: true }) + // A live partition DB that copies fine but is not openable as SQLite. + writeFileSync(targetCookiesPath, 'not a sqlite database') + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + // The summary counts importable cookies, not staged rows. + expect(result.ok && result.summary?.importedCookies).toBe(1) + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — registering a staged path that was never written would make the next cold start + // replay a missing or partial DB over the live partition. + it('never registers a restart replay when staging is unavailable', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + mkdirSync(dirname(targetCookiesPath), { recursive: true }) + writeFileSync(targetCookiesPath, 'not a sqlite database') + // Forces the restart fallback to be the only way these cookies could ever land. + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + // An older staged DB must not survive an import that already rewrote the live session. + expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test') + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + // Why: the jar was cleared and nothing replaced it, so this must not read as a clean success. + expect(result.ok && result.summary?.warning).toEqual({ + code: 'restart-fallback-unavailable', + loadedCookies: 0, + failedCookies: 1 + }) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — a staging write that fails mid-transaction must disable the restart fallback + // rather than abort an import whose in-memory half still works. + it('still imports in-memory when a staging insert fails', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + const targetDb = createChromiumCookieTestDatabase(targetCookiesPath, []) + // Rejects every staged row the way a corrupt index or disk error would. + targetDb.exec( + `CREATE TRIGGER reject_insert BEFORE INSERT ON cookies + BEGIN SELECT RAISE(ABORT, 'staging write failed'); END` + ) + targetDb.close() + // Why: without a memory failure, memoryFailed === 0 would suppress registration on its own and + // the assertion below would pass even if the insert failure never disabled staging. + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'source-value' }) + ) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test') + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + + // Why: #9355 — a successful in-memory import must retire any older staged replay, or the next + // cold start will overwrite the live session with a stale snapshot. + it('clears a stale staged replay after an import that fully succeeds in memory', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(clearPendingCookieImportMock).toHaveBeenCalledWith('persist:test') + // A fully in-memory import needs no restart, so it must not warn. + expect(result.ok && result.summary?.warning).toBeUndefined() + } finally { + platformSpy.mockRestore() + } + }) + + // Why: the staged path is the restart fallback's only input, so a working staging DB must register it. + it('registers the staged database when cookies need a restart', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: 'source-value' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok).toBe(true) + expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1) + const [partition, stagedPath] = setPendingCookieImportMock.mock.calls[0] + expect(partition).toBe('persist:test') + expect(existsSync(stagedPath as string)).toBe(true) + } finally { + platformSpy.mockRestore() + } }) }) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 7bcfd4ea247..e7703a1f5e5 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -80,6 +80,7 @@ import { type ChromiumCookieSnapshot } from './chromium-cookie-snapshot' import { resolveChromiumCookiesPath } from './chromium-cookie-path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' // --------------------------------------------------------------------------- // Browser detection @@ -1449,17 +1450,24 @@ export async function importCookiesFromBrowser( stagingDir, `Cookies-${partitionSegment}-${Date.now()}-${randomUUID()}` ) + // Why: #9355 — staging only backs the cold-restart replay for cookies the in-memory + // import rejects, so losing it must degrade that fallback rather than abort the import. + let stagingAvailable = false try { mkdirSync(stagingDir, { recursive: true }) - copyFileSync(liveCookiesPath, stagingCookiesPath) - } catch { + copyFileWithWindowsRetry(liveCookiesPath, stagingCookiesPath) + stagingAvailable = true + } catch (err) { + const fsErr = err as NodeJS.ErrnoException + diag( + ` staging copy unavailable: code=${fsErr.code ?? 'unknown'} errno=${fsErr.errno ?? 'unknown'} syscall=${fsErr.syscall ?? 'unknown'} path=${liveCookiesPath} destination=${stagingCookiesPath}` + ) // Why: copyFile is non-atomic and can leave a partial DB; delete it so failed imports retain no cookie data. try { unlinkSync(stagingCookiesPath) } catch { /* best-effort */ } - return { ok: false, reason: 'Could not create staging cookie database.' } } let sourceSnapshot: ChromiumCookieSnapshot @@ -1481,6 +1489,21 @@ export async function importCookiesFromBrowser( let sourceDb: InstanceType | null = null let stagingDb: InstanceType | null = null + const closeStagingDb = (): void => { + try { + stagingDb?.close() + } catch { + /* best-effort */ + } + stagingDb = null + } + const discardStagingFile = (): void => { + try { + unlinkSync(stagingCookiesPath) + } catch { + /* best-effort */ + } + } try { // Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss. @@ -1488,15 +1511,32 @@ export async function importCookiesFromBrowser( readOnly: true, readBigInts: true }) - stagingDb = new DatabaseSync(stagingCookiesPath) - - const targetColumnInfo = stagingDb - .prepare('PRAGMA table_info(cookies)') - .all() as ChromiumCookieColumnInfo[] - const targetCols: string[] = targetColumnInfo.map((r) => r.name) - const colList = targetCols.join(', ') - - stagingDb.exec('DELETE FROM cookies') + let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null + let colList: string | null = null + let placeholders: string | null = null + if (stagingAvailable) { + // Why: the staged file is Orca's own partition DB, also named "Cookies", so the same + // transient AV handle can make opening it throw — degrade instead of killing the import. + try { + stagingDb = new DatabaseSync(stagingCookiesPath) + targetColumnInfo = stagingDb + .prepare('PRAGMA table_info(cookies)') + .all() as ChromiumCookieColumnInfo[] + const targetCols: string[] = targetColumnInfo.map((r) => r.name) + colList = targetCols.join(', ') + placeholders = targetCols.map(() => '?').join(', ') + stagingDb.exec('DELETE FROM cookies') + } catch (err) { + diag(` staging database unusable, restart fallback disabled: ${err}`) + stagingAvailable = false + targetColumnInfo = null + colList = null + placeholders = null + closeStagingDb() + // Why: the copy holds real partition cookies; discard it now rather than at the exit branches. + discardStagingFile() + } + } const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record< string, @@ -1508,13 +1548,8 @@ export async function importCookiesFromBrowser( diag(` source has ${sourceRows.length} cookies`) if (sourceRows.length === 0) { - stagingDb.close() - stagingDb = null - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } + closeStagingDb() + discardStagingFile() return { ok: false, reason: `No cookies found in ${browser.label}.` } } @@ -1526,14 +1561,9 @@ export async function importCookiesFromBrowser( ? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser) : null if (needsSourceKey && !sourceKey) { - stagingDb.close() - stagingDb = null + closeStagingDb() // Why: key denial happens after staging, so clean up the target DB copy or retries pile up. - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ - } + discardStagingFile() return { ok: false, reason: `Could not access ${browser.label} encryption key. The OS may have denied access.` @@ -1577,12 +1607,29 @@ export async function importCookiesFromBrowser( const decryptedCookies: DecryptedCookie[] = [] - const placeholders = targetCols.map(() => '?').join(', ') - const insertStmt = stagingDb.prepare( - `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` - ) + // Why: staging only backs the cold-restart replay, so any failure writing it disables that + // fallback instead of aborting an import whose in-memory half still works. + let insertStmt: ReturnType['prepare']> | null = null + const disableStaging = (reason: string): void => { + diag(` staging disabled, restart fallback unavailable: ${reason}`) + stagingAvailable = false + insertStmt = null + closeStagingDb() + discardStagingFile() + } - stagingDb.exec('BEGIN TRANSACTION') + if (stagingDb && colList && placeholders) { + try { + insertStmt = stagingDb.prepare( + `INSERT OR REPLACE INTO cookies (${colList}) VALUES (${placeholders})` + ) + stagingDb.exec('BEGIN TRANSACTION') + } catch (err) { + disableStaging(String(err)) + } + } else if (stagingAvailable) { + disableStaging('staged database exposed no cookies columns') + } for (const sourceRow of sourceRows) { const encRaw = sourceRow.encrypted_value @@ -1637,17 +1684,35 @@ export async function importCookiesFromBrowser( expirationDate: expiresUtc > 0 ? expiresUtc : undefined }) - const params = buildChromiumCookieInsertParams(targetColumnInfo, sourceRow, decryptedValue) - insertStmt.run(...params) + if (insertStmt && targetColumnInfo) { + try { + const params = buildChromiumCookieInsertParams( + targetColumnInfo, + sourceRow, + decryptedValue + ) + insertStmt.run(...params) + } catch (err) { + disableStaging(String(err)) + } + } + // Why: counts importable cookies, not staged rows — the summary must stay truthful when + // the optional staging DB is unavailable. imported++ } diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) - stagingDb.exec('COMMIT') - stagingDb.close() - stagingDb = null - - diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) + if (stagingDb) { + try { + stagingDb.exec('COMMIT') + closeStagingDb() + diag(` SQLite staging complete: ${imported} cookies, ${domainSet.size} domains`) + } catch (err) { + disableStaging(String(err)) + } + } else { + diag(` staging skipped: ${imported} cookies will load in-memory only`) + } // Why: clear stale cookies first; mixing them with the imported set makes sites like Google reject the session. await targetSession.clearStorageData({ storages: ['cookies'] }) @@ -1684,16 +1749,27 @@ export async function importCookiesFromBrowser( diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`) - if (memoryFailed > 0) { + let warning: BrowserCookieImportSummary['warning'] + if (memoryFailed > 0 && stagingAvailable) { // Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation. browserSessionRegistry.setPendingCookieImport(targetPartition, stagingCookiesPath) diag(` staged at ${stagingCookiesPath} for ${memoryFailed} cookies that need restart`) - } else { - try { - unlinkSync(stagingCookiesPath) - } catch { - /* best-effort */ + } else if (memoryFailed > 0) { + // Why: never register a path that was never written — cold start would replay a missing + // or partial DB over the live partition. + browserSessionRegistry.clearPendingCookieImport(targetPartition) + discardStagingFile() + diag(` ${memoryFailed} cookies need a restart but staging is unavailable — skipped`) + // Why: the jar was already cleared, so silence here would report a lossy import as a clean success. + warning = { + code: 'restart-fallback-unavailable', + loadedCookies: memoryLoaded, + failedCookies: memoryFailed } + } else { + // Why: this import already rewrote the live session, so an older staged DB must not replay over it. + browserSessionRegistry.clearPendingCookieImport(targetPartition) + discardStagingFile() diag(` all cookies loaded in-memory — no restart needed`) } @@ -1709,7 +1785,8 @@ export async function importCookiesFromBrowser( totalCookies: sourceRows.length, importedCookies: imported, skippedCookies: skipped, - domains: [...domainSet].sort() + domains: [...domainSet].sort(), + ...(warning ? { warning } : {}) } return { ok: true, profileId: '', summary } diff --git a/src/main/browser/browser-session-registry.persistence.test.ts b/src/main/browser/browser-session-registry.persistence.test.ts index 174b5aa7715..50166f6f52e 100644 --- a/src/main/browser/browser-session-registry.persistence.test.ts +++ b/src/main/browser/browser-session-registry.persistence.test.ts @@ -235,6 +235,91 @@ describe('BrowserSessionRegistry persistence', () => { }) }) + it('clears only the requested partition and unlinks its staged database files', async () => { + const otherPartition = 'persist:orca-browser-session-bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: '/staged/default', + pendingCookieImports: { + 'persist:orca-browser': '/staged/default', + [otherPartition]: '/staged/other' + }, + profiles: [] + }) + for (const suffix of ['', '-wal', '-shm']) { + fsState.files.set(`/staged/other${suffix}`, 'db') + fsState.present.add(`/staged/other${suffix}`) + fsState.files.set(`/staged/default${suffix}`, 'db') + fsState.present.add(`/staged/default${suffix}`) + } + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.clearPendingCookieImport(otherPartition) + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieImports).toEqual({ 'persist:orca-browser': '/staged/default' }) + // Why: the default partition still has a staged replay, so the legacy pointer must survive. + expect(written.pendingCookieDbPath).toBe('/staged/default') + for (const suffix of ['', '-wal', '-shm']) { + expect(fsState.present.has(`/staged/other${suffix}`)).toBe(false) + expect(fsState.present.has(`/staged/default${suffix}`)).toBe(true) + } + }) + + it('drops the legacy pointer when the default partition is the one cleared', async () => { + const otherPartition = 'persist:orca-browser-session-cccccccc-cccc-4ccc-8ccc-cccccccccccc' + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: '/staged/default', + pendingCookieImports: { + 'persist:orca-browser': '/staged/default', + [otherPartition]: '/staged/other' + }, + profiles: [] + }) + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + + browserSessionRegistry.clearPendingCookieImport('persist:orca-browser') + + const written = JSON.parse(fsState.files.get(META_PATH) ?? '{}') + expect(written.pendingCookieImports).toEqual({ [otherPartition]: '/staged/other' }) + expect(written.pendingCookieDbPath).toBeNull() + }) + + it('is a no-op when the partition has no pending import', async () => { + const fsState = createFsState() + seedMeta(fsState, { + defaultSource: null, + userAgent: null, + userAgentByPartition: {}, + pendingCookieDbPath: '/staged/default', + pendingCookieImports: { 'persist:orca-browser': '/staged/default' }, + profiles: [] + }) + fsState.files.set('/staged/default', 'db') + fsState.present.add('/staged/default') + + installModuleMocks(fsState) + const { browserSessionRegistry } = await import('./browser-session-registry') + const metaBefore = fsState.files.get(META_PATH) + + browserSessionRegistry.clearPendingCookieImport('persist:orca-browser-session-unknown') + + // Why: an absent key must not rewrite meta or touch another partition's staged file. + expect(fsState.files.get(META_PATH)).toBe(metaBefore) + expect(fsState.present.has('/staged/default')).toBe(true) + }) + it('restores persisted UA for non-default partitions', async () => { const importedPartition = 'persist:orca-browser-session-11111111-1111-4111-8111-111111111111' const importedUa = 'Mozilla/5.0 Chrome/120.0.0.0 Safari/537.36' diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index 72a888edb62..1dfd03c778e 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -290,6 +290,28 @@ class BrowserSessionRegistry { }) } + // Why: a degraded import still rewrites the live session, so an older staged DB must stop replaying over it. + clearPendingCookieImport(partition: string): void { + const meta = this.loadPersistedMeta() + if (!(partition in meta.pendingCookieImports)) { + return + } + const pendingCookieImports = { ...meta.pendingCookieImports } + const stagedPath = pendingCookieImports[partition] + delete pendingCookieImports[partition] + this.persistMeta({ + pendingCookieImports, + pendingCookieDbPath: pendingCookieImports[this.defaultPartition] ?? null + }) + for (const suffix of ['', '-wal', '-shm']) { + try { + unlinkSync(stagedPath + suffix) + } catch { + /* best-effort */ + } + } + } + persistUserAgent(partition: string, userAgent: string | null): void { const meta = this.loadPersistedMeta() const userAgentByPartition = { ...meta.userAgentByPartition } diff --git a/src/main/browser/browser-text-insertion.ts b/src/main/browser/browser-text-insertion.ts index 9acee214399..4d89b3a06fa 100644 --- a/src/main/browser/browser-text-insertion.ts +++ b/src/main/browser/browser-text-insertion.ts @@ -1,4 +1,5 @@ import { measureClipboardTextByteLength } from '../../shared/clipboard-text' +import { yieldToEventLoop } from '../../shared/event-loop-yield' import type { CdpCommandSender } from './snapshot-engine' export const BROWSER_TEXT_INSERT_CHUNK_BYTES = 64 * 1024 @@ -71,7 +72,7 @@ export async function insertTextThroughCdp( // process responsive between bounded CDP payloads. chunk = chunks.next() if (options?.yieldBetweenChunks !== false && !chunk.done) { - await new Promise((resolve) => setTimeout(resolve, 0)) + await yieldToEventLoop() } } } diff --git a/src/main/browser/chromium-cookie-snapshot.test.ts b/src/main/browser/chromium-cookie-snapshot.test.ts index 16fbd50a63f..f2d6e322d96 100644 --- a/src/main/browser/chromium-cookie-snapshot.test.ts +++ b/src/main/browser/chromium-cookie-snapshot.test.ts @@ -156,6 +156,63 @@ describe('createChromiumCookieSnapshot', () => { expect(readdirSync(snapshotsRoot)).toEqual([]) }) + // Why: #9355 — the attempt loop only reacts to a `false` return, so before the retry a + // transient AV/EDR lock on the main copy threw straight out of the snapshot. + it('retries a transient Windows lock on the main database copy', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + createChromiumCookieTestDatabase(sourcePath, [{ name: 'sid', value: 'locked-value' }]).close() + let mainCopyAttempts = 0 + beforeCopyMock.mockImplementation((source: string) => { + if (source !== sourcePath) { + return + } + mainCopyAttempts += 1 + if (mainCopyAttempts === 1) { + throw Object.assign(new Error('EBUSY: resource busy or locked, copyfile'), { + code: 'EBUSY' + }) + } + }) + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + + try { + const snapshot = createChromiumCookieSnapshot(sourcePath, { tempRoot: root }) + const database = new DatabaseSync(snapshot.databasePath, { readOnly: true }) + const row = database.prepare('SELECT name, value FROM cookies').get() + database.close() + + expect(row).toEqual(expect.objectContaining({ name: 'sid', value: 'locked-value' })) + expect(mainCopyAttempts).toBe(2) + snapshot.cleanup() + } finally { + platformSpy.mockRestore() + } + }) + + // Why: the retry is Windows-scoped, so POSIX keeps failing fast instead of sleeping. + it('does not retry a locked copy off Windows', () => { + const sourcePath = join(root, 'Chrome', 'Default', 'Cookies') + const snapshotsRoot = join(root, 'snapshots') + mkdirSync(snapshotsRoot) + createChromiumCookieTestDatabase(sourcePath, [{ name: 'sid', value: 'value' }]).close() + let mainCopyAttempts = 0 + beforeCopyMock.mockImplementation(() => { + mainCopyAttempts += 1 + throw Object.assign(new Error('EBUSY: resource busy or locked, copyfile'), { code: 'EBUSY' }) + }) + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + expect(() => createChromiumCookieSnapshot(sourcePath, { tempRoot: snapshotsRoot })).toThrow( + 'EBUSY' + ) + expect(mainCopyAttempts).toBe(1) + expect(readdirSync(snapshotsRoot)).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) + it('removes its temporary directory when the source database is missing', () => { const snapshotsRoot = join(root, 'snapshots') mkdirSync(snapshotsRoot) diff --git a/src/main/browser/chromium-cookie-snapshot.ts b/src/main/browser/chromium-cookie-snapshot.ts index 4b54e98be8d..452dfca0027 100644 --- a/src/main/browser/chromium-cookie-snapshot.ts +++ b/src/main/browser/chromium-cookie-snapshot.ts @@ -1,6 +1,7 @@ -import { copyFileSync, mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' +import { mkdtempSync, rmSync, statSync, unlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { copyFileWithWindowsRetry } from '../codex-accounts/fs-utils' const SNAPSHOT_ATTEMPTS = 5 @@ -83,12 +84,15 @@ function copyStableAttempt(sourcePath: string, databasePath: string): boolean { } removeAttemptFiles(databasePath) - copyFileSync(sourcePath, databasePath) + // Why: #9355 — AV/EDR briefly opens a file literally named "Cookies" with FILE_SHARE_NONE, + // so an otherwise-fine copy throws EBUSY. The attempt loop below only handles a false + // return, so a throw here would escape it entirely. + copyFileWithWindowsRetry(sourcePath, databasePath) if (walBefore) { try { // Why: SQLite only discovers a WAL whose basename exactly matches the DB. - copyFileSync(sourceWalPath, `${databasePath}-wal`) + copyFileWithWindowsRetry(sourceWalPath, `${databasePath}-wal`) } catch (error) { if (isMissingFileError(error)) { return false diff --git a/src/main/claude-usage/scanner.ts b/src/main/claude-usage/scanner.ts index db1c1f21f7c..565f7e49c81 100644 --- a/src/main/claude-usage/scanner.ts +++ b/src/main/claude-usage/scanner.ts @@ -124,8 +124,10 @@ function getSortedWorktreeEntries( return sorted } +// Why setImmediate: setTimeout(0) is clamped to ~1ms, and this yields once per +// 4-file batch, so a 7.5k-transcript scan spent ~2s parked on timers. async function yieldToEventLoop(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)) + await new Promise((resolve) => setImmediate(resolve)) } async function walkJsonlFiles(dirPath: string): Promise { diff --git a/src/main/claude-usage/store.test.ts b/src/main/claude-usage/store.test.ts index 08a76e4696c..85d85d8bac9 100644 --- a/src/main/claude-usage/store.test.ts +++ b/src/main/claude-usage/store.test.ts @@ -254,6 +254,125 @@ describe('ClaudeUsageStore', () => { ).toBeCloseTo(36.75) }) + it('prices Claude 5 family models with current Anthropic rates', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'claude-opus-5', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000 + }, + { + day: '2026-04-09', + model: 'anthropic/claude-fable-5', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000 + }, + { + day: '2026-04-09', + model: 'claude-sonnet-5-thinking', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 1_000_000, + outputTokens: 1_000_000, + cacheReadTokens: 1_000_000, + cacheWriteTokens: 1_000_000 + } + ] + }) + + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(breakdown.find((row) => row.key === 'claude-opus-5')?.estimatedCostUsd).toBeCloseTo( + 36.75 + ) + expect( + breakdown.find((row) => row.key === 'anthropic/claude-fable-5')?.estimatedCostUsd + ).toBeCloseTo(73.5) + expect( + breakdown.find((row) => row.key === 'claude-sonnet-5-thinking')?.estimatedCostUsd + ).toBeCloseTo(22.05) + }) + + it('prices Sonnet 5 long-context usage at flat rates', async () => { + const store = createStoreWithState({ + dailyAggregates: [ + { + day: '2026-04-09', + model: 'claude-sonnet-5', + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 300_000, + outputTokens: 300_000, + cacheReadTokens: 300_000, + cacheWriteTokens: 300_000 + } + ] + }) + + const summary = await store.getSummary('orca', '30d') + + // Why: Sonnet 4.6 and earlier bill above 200k at a premium; Sonnet 5 does not. + expect(summary.estimatedCostUsd).toBeCloseTo(6.615) + }) + + it('does not collapse Opus 4.5 or Sonnet 4.5 usage into Claude 5 pricing', async () => { + const store = createStoreWithState({ + dailyAggregates: ['claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'].map((model) => ({ + day: '2026-04-09', + model, + projectKey: 'worktree:repo-1::/workspace/repo-a', + projectLabel: 'Repo A', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo-a', + turnCount: 1, + zeroCacheReadTurnCount: 0, + inputTokens: 300_000, + outputTokens: 300_000, + cacheReadTokens: 300_000, + cacheWriteTokens: 300_000 + })) + }) + + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + // Why: the 4.5 tier premium only survives if `-4-5-` never matches the `-5` + // family regex, so this doubles as the digit-boundary proof for both families. + expect( + breakdown.find((row) => row.key === 'claude-sonnet-4-5-20250929')?.estimatedCostUsd + ).toBeCloseTo(8.07) + // Why: Opus 4.5 and Opus 5 share rates today, so this pins the rate rather + // than the routing — it fails only if the two ever diverge. + expect( + breakdown.find((row) => row.key === 'claude-opus-4-5-20251101')?.estimatedCostUsd + ).toBeCloseTo(11.025) + }) + it('prices unknown newer Opus 4 point releases with current Opus rates', async () => { const store = createStoreWithState({ dailyAggregates: [ diff --git a/src/main/claude-usage/store.ts b/src/main/claude-usage/store.ts index a020186ee90..549758fc69f 100644 --- a/src/main/claude-usage/store.ts +++ b/src/main/claude-usage/store.ts @@ -59,6 +59,11 @@ const SONNET_LONG_CONTEXT_PRICING = { } satisfies Partial const MODEL_PRICING: Record = { + 'claude-fable-5': { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + 'claude-opus-5': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + // Why: Sonnet 5 bills its full 1M window at flat rates, so no long-context tier here. + // Why: standard rates, not the $2/$10 introductory rate ending 2026-08-31 — no date dimension. + 'claude-sonnet-5': { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, 'claude-opus-4-8': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, 'claude-opus-4-7': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, 'claude-opus-4-6': { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, @@ -156,6 +161,12 @@ function normalizeModelForPricing(model: string | null): string | null { if (alias) { return alias } + if (hasClaudeModelVersion(lower, 'fable', '5')) { + return 'claude-fable-5' + } + if (hasClaudeModelVersion(lower, 'opus', '5')) { + return 'claude-opus-5' + } if (hasClaudeModelVersion(lower, 'opus', '4-8')) { return 'claude-opus-4-8' } @@ -179,6 +190,9 @@ function normalizeModelForPricing(model: string | null): string | null { // avoid overbilling unknown future Claude Code model IDs as legacy Opus 4. return 'claude-opus-4-8' } + if (hasClaudeModelVersion(lower, 'sonnet', '5')) { + return 'claude-sonnet-5' + } if (hasClaudeModelVersion(lower, 'sonnet', '4-6')) { return 'claude-sonnet-4-6' } diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index e94067c4265..9546c2f251f 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -140,6 +140,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, disabledTuiAgents: [], + pluginSystemEnabled: false, + disabledPlugins: [], + pluginConsents: {}, + devPluginPaths: [], skipDeleteWorktreeConfirm: false, skipCloseTerminalWithRunningProcessConfirm: false, skipDeleteAutomationConfirm: false, diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index 9b1340b3e70..ee172a81e20 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -362,6 +362,22 @@ export class CodexRuntimeHomeService { return homes.filter((home, index) => homes.indexOf(home) === index) } + /** + * The account-owned CODEX_HOME the current HOST selection runs against, or + * null when the selection is not routed to one (system default, or the + * flag-OFF shared mirror, which every account hot-swaps and so names no + * account). + * + * Read-only on purpose: session discovery ranks homes with this before any + * launch prep, so it must create no directories and sync no auth. + */ + getSelectedHostAccountCodexHomePath(): string | null { + const selfContainedAccount = this.getSelfContainedManagedHostAccount() + return selfContainedAccount + ? this.getTrustedSelfContainedManagedHomePath(selfContainedAccount) + : null + } + // Why: the real-home hook installer flips this gate off when the trust-grant // client reports the host incapable, keeping that host byte-identical to the // managed lane instead of shipping status-blind panes. diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index cb6ba306f2a..c2ae74b8911 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -148,6 +148,10 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { terminalScopeHistoryByWorktree: true, defaultTuiAgent: null, disabledTuiAgents: [], + pluginSystemEnabled: false, + disabledPlugins: [], + pluginConsents: {}, + devPluginPaths: [], skipDeleteWorktreeConfirm: false, skipCloseTerminalWithRunningProcessConfirm: false, skipDeleteAutomationConfirm: false, diff --git a/src/main/codex-usage/store.test.ts b/src/main/codex-usage/store.test.ts index 055af8f3a23..a20472abd25 100644 --- a/src/main/codex-usage/store.test.ts +++ b/src/main/codex-usage/store.test.ts @@ -364,6 +364,92 @@ describe('CodexUsageStore', () => { expect(breakdown.find((row) => row.key === 'gpt-5.5')?.estimatedCostUsd).toBeCloseTo(50.424) }) + it('prices GPT-5.6 sol, terra, and luna with current OpenAI rates', async () => { + const store = createStoreWithState({ + dailyAggregates: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'].map((model) => ({ + day: '2026-04-09', + model, + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 2_000_000, + cachedInputTokens: 1_000_000, + outputTokens: 1_000_000, + reasoningOutputTokens: 100_000, + totalTokens: 3_000_000, + hasInferredPricing: false + })) + }) + + const summary = await store.getSummary('orca', '30d') + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(summary.estimatedCostUsd).toBeCloseTo(85.7208) + expect(breakdown.find((row) => row.key === 'gpt-5.6-sol')?.estimatedCostUsd).toBeCloseTo(50.424) + expect(breakdown.find((row) => row.key === 'gpt-5.6-terra')?.estimatedCostUsd).toBeCloseTo( + 25.212 + ) + expect(breakdown.find((row) => row.key === 'gpt-5.6-luna')?.estimatedCostUsd).toBeCloseTo( + 10.0848 + ) + }) + + it('normalizes GPT-5.6 reasoning suffixes before pricing', async () => { + const store = createStoreWithState({ + dailyAggregates: ['gpt-5.6-terra-high', 'gpt-5.6-luna(medium)'].map((model) => ({ + day: '2026-04-09', + model, + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 100_000, + cachedInputTokens: 50_000, + outputTokens: 25_000, + reasoningOutputTokens: 5_000, + totalTokens: 125_000, + hasInferredPricing: false + })) + }) + + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(breakdown.find((row) => row.key === 'gpt-5.6-terra-high')?.estimatedCostUsd).toBeCloseTo( + 0.5125 + ) + expect( + breakdown.find((row) => row.key === 'gpt-5.6-luna(medium)')?.estimatedCostUsd + ).toBeCloseTo(0.205) + }) + + it('prices the bare gpt-5.6 alias at Sol rates without shadowing the tier IDs', async () => { + const store = createStoreWithState({ + dailyAggregates: ['gpt-5.6', 'gpt-5.6-luna'].map((model) => ({ + day: '2026-04-09', + model, + projectKey: 'worktree:repo-1::/workspace/repo', + projectLabel: 'Repo', + repoId: 'repo-1', + worktreeId: 'repo-1::/workspace/repo', + eventCount: 1, + inputTokens: 100_000, + cachedInputTokens: 50_000, + outputTokens: 25_000, + reasoningOutputTokens: 5_000, + totalTokens: 125_000, + hasInferredPricing: false + })) + }) + + const breakdown = await store.getBreakdown('orca', '30d', 'model') + + expect(breakdown.find((row) => row.key === 'gpt-5.6')?.estimatedCostUsd).toBeCloseTo(1.025) + expect(breakdown.find((row) => row.key === 'gpt-5.6-luna')?.estimatedCostUsd).toBeCloseTo(0.205) + }) + it('normalizes Codex model variants and reasoning suffixes before pricing', async () => { const store = createStoreWithState({ dailyAggregates: [ diff --git a/src/main/codex-usage/store.ts b/src/main/codex-usage/store.ts index ca42ba34061..f3c62fb8973 100644 --- a/src/main/codex-usage/store.ts +++ b/src/main/codex-usage/store.ts @@ -90,6 +90,30 @@ const MODEL_PRICING: Record = { inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }], cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }], outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }] + }, + 'gpt-5.6-sol': { + input: 5, + cachedInput: 0.5, + output: 30, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 10 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 1 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 45 }] + }, + 'gpt-5.6-terra': { + input: 2.5, + cachedInput: 0.25, + output: 15, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 5 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.5 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 22.5 }] + }, + 'gpt-5.6-luna': { + input: 1, + cachedInput: 0.1, + output: 6, + inputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 2 }], + cachedInputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 0.2 }], + outputTiers: [{ threshold: LONG_CONTEXT_THRESHOLD_TOKENS, price: 9 }] } } @@ -228,6 +252,21 @@ function normalizeModelForPricing(model: string | null): string | null { if (normalized === 'gpt-5.5' || normalized.startsWith('gpt-5.5-')) { return 'gpt-5.5' } + if (normalized === 'gpt-5.6-sol' || normalized.startsWith('gpt-5.6-sol-')) { + return 'gpt-5.6-sol' + } + if (normalized === 'gpt-5.6-terra' || normalized.startsWith('gpt-5.6-terra-')) { + return 'gpt-5.6-terra' + } + if (normalized === 'gpt-5.6-luna' || normalized.startsWith('gpt-5.6-luna-')) { + return 'gpt-5.6-luna' + } + // Why: OpenAI routes the bare `gpt-5.6` alias to Sol. Match it exactly — a + // `gpt-5.6-` prefix match would swallow the tier IDs above and any future + // cheaper variant. + if (normalized === 'gpt-5.6') { + return 'gpt-5.6-sol' + } return null } diff --git a/src/main/codex/codex-session-resume-home.test.ts b/src/main/codex/codex-session-resume-home.test.ts index 4f55dec236d..94a7a89f3fe 100644 --- a/src/main/codex/codex-session-resume-home.test.ts +++ b/src/main/codex/codex-session-resume-home.test.ts @@ -3,12 +3,20 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { + claimsCodexRolloutLayout, findTrustedCodexSessionResume, resolveTrustedCodexSessionResumeHome } from './codex-session-resume-home' const tempRoots: string[] = [] +// Why: the ranking inputs are required by design; cases below that never reach the rescan stay neutral. +const withoutHomeRanking = { + getSelectedAccountCodexHome: (): string | null => null, + systemCodexHomePath: null, + sharedRuntimeCodexHomePath: null +} + afterEach(() => { for (const root of tempRoots.splice(0)) { rmSync(root, { recursive: true, force: true }) @@ -115,7 +123,8 @@ describe('resolveTrustedCodexSessionResumeHome', () => { findTrustedCodexSessionResume({ sessionId: 'session-a', transcriptPath: plainPath, - trustedCodexHomes: [homePath] + trustedCodexHomes: [homePath], + ...withoutHomeRanking }) ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) @@ -124,7 +133,8 @@ describe('resolveTrustedCodexSessionResumeHome', () => { findTrustedCodexSessionResume({ sessionId: 'session-a', transcriptPath: compressedPath, - trustedCodexHomes: [homePath] + trustedCodexHomes: [homePath], + ...withoutHomeRanking }) ).resolves.toEqual({ homePath, transcriptPath: plainPath }) }) @@ -148,7 +158,8 @@ describe('resolveTrustedCodexSessionResumeHome', () => { findTrustedCodexSessionResume({ sessionId, transcriptPath: undefined, - trustedCodexHomes: [homePath] + trustedCodexHomes: [homePath], + ...withoutHomeRanking }) ).resolves.toEqual({ homePath, transcriptPath: compressedPath }) }) @@ -168,6 +179,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId, transcriptPath: undefined, trustedCodexHomes: ['/Users/example/.codex', '/managed/account/home'], + ...withoutHomeRanking, listSessionFiles }) ).resolves.toEqual({ homePath: '/managed/account/home', transcriptPath: rolloutPath }) @@ -185,6 +197,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId: 'session-a', transcriptPath, trustedCodexHomes: ['/managed/account/home'], + ...withoutHomeRanking, fileIsRegular: () => true, listSessionFiles }) @@ -203,6 +216,7 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId, transcriptPath: `/managed/origin/home/sessions/2026/07/20/rollout-${sessionId}.jsonl`, trustedCodexHomes: ['/managed/origin/home', '/managed/other/home'], + ...withoutHomeRanking, fileIsRegular: () => false, listSessionFiles }) @@ -219,8 +233,232 @@ describe('resolveTrustedCodexSessionResumeHome', () => { sessionId: '../session', transcriptPath: undefined, trustedCodexHomes: ['/Users/example/.codex'], + ...withoutHomeRanking, listSessionFiles }) ).resolves.toBeNull() }) }) + +describe('findTrustedCodexSessionResume legacy-rescan home ranking', () => { + const sessionId = '019f81b9-19a9-7651-a8d1-352d9420bd11' + const systemHome = join('/Users', 'example', '.codex') + const sharedMirror = join('/userData', 'codex-runtime-home', 'home') + const accountAHome = join('/userData', 'codex-accounts', 'account-a', 'home') + const accountBHome = join('/userData', 'codex-accounts', 'account-b', 'home') + + const rolloutIn = (homePath: string): string => + join(homePath, 'sessions', '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`) + + // Why: one id already lives in several homes on main — the one-shot migrateLegacySessions copies + // each per-account rollout into the shared mirror and leaves the original. #10770 widens this to + // every managed home. Either way the id alone stops naming an account. + const listRolloutInEveryHome = async function* (sessionsRoot: string): AsyncIterable { + yield join(sessionsRoot, '2026', '07', '20', `rollout-2026-07-20T15-50-19-${sessionId}.jsonl`) + } + + const listRolloutIn = (...homePaths: string[]) => + async function* (sessionsRoot: string): AsyncIterable { + if (homePaths.some((homePath) => sessionsRoot === join(homePath, 'sessions'))) { + yield* listRolloutInEveryHome(sessionsRoot) + } + } + + it('resumes into the selected account home whatever order the homes arrive in', async () => { + for (const trustedCodexHomes of [ + [systemHome, sharedMirror, accountAHome, accountBHome], + [systemHome, sharedMirror, accountBHome, accountAHome], + [accountBHome, accountAHome, sharedMirror, systemHome] + ]) { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes, + getSelectedAccountCodexHome: () => accountBHome, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) }) + } + }) + + it('falls back to the real system home when no account home is selected', async () => { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [sharedMirror, accountAHome, systemHome], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ homePath: systemHome, transcriptPath: rolloutIn(systemHome) }) + }) + + // Why: `/Users/…` already wins the tier-3 byte order, so the case above cannot tell the + // system-home tier apart from the path tie-break. Pin it with a home that sorts last. + it('ranks the real system home above the others even when its path sorts last', async () => { + const lateSortingSystemHome = join('/var', 'lib', 'orca', '.codex') + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [sharedMirror, accountAHome, lateSortingSystemHome], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: lateSortingSystemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ + homePath: lateSortingSystemHome, + transcriptPath: rolloutIn(lateSortingSystemHome) + }) + }) + + it('orders the remaining homes by path so insertion order never decides', async () => { + for (const trustedCodexHomes of [ + [accountBHome, accountAHome], + [accountAHome, accountBHome] + ]) { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes, + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutInEveryHome + }) + ).resolves.toEqual({ homePath: accountAHome, transcriptPath: rolloutIn(accountAHome) }) + } + }) + + // Why: the mirror is the only home whose win migrates the rollout into ~/.codex + // (prepareLegacySharedCodexSessionResume), which is how a system-default selection resumes on the + // real home. 'codex-accounts' sorts before 'codex-runtime-home', so path order alone would hand + // that selection to an arbitrary account instead. One-shot legacy migration already copies + // per-account rollouts into the mirror, so both really do hold the id. + it('prefers the shared mirror over a per-account home when the system home lacks the id', async () => { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [systemHome, accountAHome, sharedMirror], + getSelectedAccountCodexHome: () => null, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutIn(sharedMirror, accountAHome) + }) + ).resolves.toEqual({ homePath: sharedMirror, transcriptPath: rolloutIn(sharedMirror) }) + }) + + it('ranks Windows homes case-insensitively and keeps the caller path spelling', async () => { + const windowsRoot = 'C:\\Users\\Example' + const windowsSystemHome = `${windowsRoot}\\.codex` + const windowsAccountAHome = `${windowsRoot}\\AppData\\Roaming\\Orca\\codex-accounts\\a\\home` + const windowsAccountBHome = `${windowsRoot}\\AppData\\Roaming\\Orca\\codex-accounts\\b\\home` + const windowsRolloutIn = (homePath: string): string => + `${join(homePath, 'sessions')}\\2026\\07\\20\\rollout-2026-07-20T15-50-19-${sessionId}.jsonl` + const listSessionFiles = async function* (sessionsRoot: string): AsyncIterable { + yield `${sessionsRoot}\\2026\\07\\20\\rollout-2026-07-20T15-50-19-${sessionId}.jsonl` + } + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [windowsSystemHome, windowsAccountAHome, windowsAccountBHome], + // Why: settings and discovery can disagree on drive/segment case; the selection must still match. + getSelectedAccountCodexHome: () => windowsAccountBHome.toLowerCase(), + systemCodexHomePath: windowsSystemHome, + sharedRuntimeCodexHomePath: null, + listSessionFiles + }) + ).resolves.toEqual({ + homePath: windowsAccountBHome, + transcriptPath: windowsRolloutIn(windowsAccountBHome) + }) + }) + + it('still resumes the only home holding the id, selected or not', async () => { + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: undefined, + trustedCodexHomes: [systemHome, sharedMirror, accountAHome, accountBHome], + getSelectedAccountCodexHome: () => accountAHome, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + listSessionFiles: listRolloutIn(accountBHome) + }) + ).resolves.toEqual({ homePath: accountBHome, transcriptPath: rolloutIn(accountBHome) }) + }) + + it('does not rescan into the selected account home when transcript provenance was rejected', async () => { + const listSessionFiles = vi.fn((): AsyncIterable => { + throw new Error('must not scan') + }) + + await expect( + findTrustedCodexSessionResume({ + sessionId, + transcriptPath: rolloutIn(accountBHome), + trustedCodexHomes: [systemHome, accountAHome, accountBHome], + getSelectedAccountCodexHome: () => accountAHome, + systemCodexHomePath: systemHome, + sharedRuntimeCodexHomePath: sharedMirror, + fileIsRegular: () => false, + listSessionFiles + }) + ).resolves.toBeNull() + expect(listSessionFiles).not.toHaveBeenCalled() + }) +}) + +describe('claimsCodexRolloutLayout', () => { + it('is true for a rollout path even if the file is missing', () => { + expect( + claimsCodexRolloutLayout('/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl') + ).toBe(true) + }) + + it('is true for compressed rollouts and Windows-separated paths', () => { + expect( + claimsCodexRolloutLayout( + '/Users/example/.codex/sessions/2026/07/20/rollout-session.jsonl.zst' + ) + ).toBe(true) + expect( + claimsCodexRolloutLayout( + 'C:\\Users\\example\\.codex\\sessions\\2026\\07\\20\\rollout-session.jsonl' + ) + ).toBe(true) + }) + + it('is true for a rollout under a home Orca no longer trusts, so resume cannot silently fall through to the selected account', () => { + expect( + claimsCodexRolloutLayout('/removed/account/home/sessions/2026/07/20/rollout-a.jsonl') + ).toBe(true) + }) + + it('is false for Claude (or other non-Codex) transcript paths', () => { + expect( + claimsCodexRolloutLayout( + '/Users/example/.claude/projects/-Users-example-repo/019f81b9-19a9-7651-a8d1-352d9420bd11.jsonl' + ) + ).toBe(false) + }) + + it('is false for empty provenance and JSONL misplaced inside a sessions root', () => { + expect(claimsCodexRolloutLayout(undefined)).toBe(false) + expect(claimsCodexRolloutLayout(' ')).toBe(false) + expect(claimsCodexRolloutLayout('/Users/example/.codex/sessions/rollout-a.jsonl')).toBe(false) + expect( + claimsCodexRolloutLayout('/Users/example/.codex/sessions/2026/07/20/nested/rollout-a.jsonl') + ).toBe(false) + }) +}) diff --git a/src/main/codex/codex-session-resume-home.ts b/src/main/codex/codex-session-resume-home.ts index aebaf6a157c..e702eda6278 100644 --- a/src/main/codex/codex-session-resume-home.ts +++ b/src/main/codex/codex-session-resume-home.ts @@ -8,7 +8,10 @@ import { import { listCodexSessionRolloutFilesIncrementally } from './codex-session-file-listing' // Why: only Codex's dated rollout layout may establish account-home provenance; nested/misplaced JSONL must not select credentials. -const ROLLOUT_RELATIVE_PATH = /^\d{4}\/\d{2}\/\d{2}\/rollout-[^/]+\.jsonl(?:\.zst)?$/ +const DATED_ROLLOUT_TAIL = String.raw`\d{4}/\d{2}/\d{2}/rollout-[^/]+\.jsonl(?:\.zst)?` +const ROLLOUT_RELATIVE_PATH = new RegExp(`^${DATED_ROLLOUT_TAIL}$`) +// Why: case-insensitive because trusted-home matching folds Windows path case too. +const CODEX_ROLLOUT_LAYOUT_PATH = new RegExp(`(?:^|/)sessions/${DATED_ROLLOUT_TAIL}$`, 'i') function isCodexRolloutInsideSessionsRoot(sessionsRoot: string, filePath: string): boolean { const relativePath = relativePathInsideRoot(sessionsRoot, filePath) @@ -76,10 +79,92 @@ export function resolveTrustedCodexSessionResumeHome(args: { return resolveTrustedCodexSessionResume(args)?.homePath ?? null } +/** + * True when transcriptPath claims Codex's dated rollout layout, under any home and without + * checking existence — separating rejected Codex provenance from cross-agent/stale metadata. + * Not scoped to trusted homes: a rollout under a removed home is still rejected provenance, + * and admitting it would resume that session under whichever account is selected now. + */ +export function claimsCodexRolloutLayout(transcriptPath: string | undefined): boolean { + const persistedPath = transcriptPath?.trim() + if (!persistedPath) { + return false + } + return CODEX_ROLLOUT_LAYOUT_PATH.test(persistedPath.replace(/\\/g, '/')) +} + +/** + * Orders trusted homes for the legacy id rescan, lowest wins. + * + * The winning home becomes the resumed pane's CODEX_HOME, so it picks the + * account. Rank the currently selected account's own home first — once the same + * rollout sits in several homes the id alone no longer names an account, and + * resuming under the account the user has selected is what + * they asked for. The real system home ranks next because codex refreshes it + * directly. Everything else is ordered by normalized path so no winner ever + * depends on the order accounts happen to sit in settings. + * + * The shared runtime mirror ranks above the remaining homes because winning is + * not inert for it: with no account selected it is the only home that triggers + * the legacy migration into the real system home + * (prepareLegacySharedCodexSessionResume, which also requires the system-default + * selection), and that migration is how such a resume lands on ~/.codex instead + * of some account's home. Letting a per-account home outrank it by mere path + * order would silently route that selection to an account the user did not pick. + * With an account selected the migration cannot fire either way, so this tier + * just preserves the pre-ranking order rather than inventing a new winner. + * + * All inputs are required, not optional: a caller that forgot one would + * silently degrade to pure path order, which is the accident this exists to + * remove. The selection arrives as a thunk because resolving it stats the + * account's ownership marker, and the far more common provenance-present + * resume never reaches the ranking at all. + */ +function rankTrustedCodexHomesForRescan(args: { + trustedCodexHomes: readonly string[] + getSelectedAccountCodexHome: () => string | null + systemCodexHomePath: string | null + sharedRuntimeCodexHomePath: string | null +}): string[] { + const toComparisonHome = (value: string | null | undefined): string | null => { + const trimmed = value?.trim() + return trimmed ? normalizeRuntimePathForComparison(trimmed) : null + } + const selectedComparison = toComparisonHome(args.getSelectedAccountCodexHome()) + const systemComparison = toComparisonHome(args.systemCodexHomePath) + const sharedRuntimeComparison = toComparisonHome(args.sharedRuntimeCodexHomePath) + const rankOf = (comparisonHome: string): number => { + if (selectedComparison && comparisonHome === selectedComparison) { + return 0 + } + if (systemComparison && comparisonHome === systemComparison) { + return 1 + } + return sharedRuntimeComparison && comparisonHome === sharedRuntimeComparison ? 2 : 3 + } + return args.trustedCodexHomes + .map((homePath) => ({ homePath, comparisonHome: normalizeRuntimePathForComparison(homePath) })) + .sort((left, right) => { + const rankDelta = rankOf(left.comparisonHome) - rankOf(right.comparisonHome) + if (rankDelta !== 0) { + return rankDelta + } + return left.comparisonHome < right.comparisonHome + ? -1 + : left.comparisonHome > right.comparisonHome + ? 1 + : 0 + }) + .map((entry) => entry.homePath) +} + export async function findTrustedCodexSessionResume(args: { sessionId: string transcriptPath: string | undefined trustedCodexHomes: readonly string[] + getSelectedAccountCodexHome: () => string | null + systemCodexHomePath: string | null + sharedRuntimeCodexHomePath: string | null fileIsRegular?: (filePath: string) => boolean listSessionFiles?: (sessionsRoot: string) => AsyncIterable }): Promise<{ homePath: string; transcriptPath: string } | null> { @@ -101,7 +186,7 @@ export async function findTrustedCodexSessionResume(args: { listCodexSessionRolloutFilesIncrementally(sessionsRoot, { batchSize: 64, yieldMs: 0 })) const expectedSuffix = `-${args.sessionId}.jsonl`.toLowerCase() const seenHomes = new Set() - for (const homePath of args.trustedCodexHomes) { + for (const homePath of rankTrustedCodexHomesForRescan(args)) { const comparisonHome = normalizeRuntimePathForComparison(homePath) if (seenHomes.has(comparisonHome)) { continue diff --git a/src/main/codex/hook-service-wsl-runtime.test.ts b/src/main/codex/hook-service-wsl-runtime.test.ts index f56ea144937..dbc51f39b35 100644 --- a/src/main/codex/hook-service-wsl-runtime.test.ts +++ b/src/main/codex/hook-service-wsl-runtime.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { dirname, join, win32 as pathWin32 } from 'node:path' import { MANAGED_HOOK_TIMEOUT_SECONDS } from '../agent-hooks/installer-utils' +import { POSIX_HOOK_STDIN_DRAIN_COMMAND } from '../agent-hooks/hook-stdin-contract' import { computeTrustKey, computeTrustedHash, @@ -77,7 +78,7 @@ function getManagedTrustEntry( } function expectedManagedCommand(scriptPath: string): string { - return `if [ -f '${scriptPath}' ] && [ -r '${scriptPath}' ]; then /bin/sh '${scriptPath}'; else cat >/dev/null 2>&1 || :; fi` + return `if [ -f '${scriptPath}' ] && [ -r '${scriptPath}' ]; then /bin/sh '${scriptPath}'; else ${POSIX_HOOK_STDIN_DRAIN_COMMAND}; fi` } describe('Codex WSL runtime hook install', () => { diff --git a/src/main/cursor/hook-service.test.ts b/src/main/cursor/hook-service.test.ts index d0460904452..f861d4f2a66 100644 --- a/src/main/cursor/hook-service.test.ts +++ b/src/main/cursor/hook-service.test.ts @@ -16,6 +16,7 @@ vi.mock('os', async () => { }) import { CursorHookService } from './hook-service' +import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const CURSOR_EVENTS = [ 'beforeSubmitPrompt', @@ -79,7 +80,7 @@ describe('CursorHookService', () => { } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain('payload=$(cat)') + expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`) expect(script).toContain('printf \'%s\' "$payload" | curl') expect(script).toContain('--data-urlencode "payload@-"') expect(script).not.toContain('--data-urlencode "payload=${payload}"') diff --git a/src/main/ephemeral-vm-runtime-service.test.ts b/src/main/ephemeral-vm-runtime-service.test.ts index 6f31e7a755d..61fc991ad83 100644 --- a/src/main/ephemeral-vm-runtime-service.test.ts +++ b/src/main/ephemeral-vm-runtime-service.test.ts @@ -83,6 +83,9 @@ describe('ephemeral VM runtime service', () => { const recipe: OrcaVmRecipe = { id: 'cloud-sandbox', name: 'Cloud Sandbox', + // Repo-owned recipes predate plugin bounds; snapshotting must not fail + // after create has already provisioned external resources. + description: 'x'.repeat(2_048), create: nodeCommand(startPath), destroy: nodeCommand(cleanupPath) } @@ -104,6 +107,7 @@ describe('ephemeral VM runtime service', () => { expect(provisioned.runtime).toMatchObject({ id: provisioned.start.context.instanceId, recipeId: 'cloud-sandbox', + recipe, repoId: 'repo-1', projectId: 'project-1', workspaceName: 'Fix Login Race', diff --git a/src/main/ephemeral-vm-runtime-service.ts b/src/main/ephemeral-vm-runtime-service.ts index 58ad107a346..90eb216c440 100644 --- a/src/main/ephemeral-vm-runtime-service.ts +++ b/src/main/ephemeral-vm-runtime-service.ts @@ -120,6 +120,7 @@ export async function provisionEphemeralVmRuntime( const runtime = upsertEphemeralVmRuntime(args.userDataPath, { id: start.context.instanceId ?? start.context.recipeId, recipeId: args.recipe.id, + recipe: args.recipe, ...(args.repoId ? { repoId: args.repoId } : {}), ...(args.projectId ? { projectId: args.projectId } : {}), ...(args.workspaceId ? { workspaceId: args.workspaceId } : {}), diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 2cdc41dc367..c39a4097825 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -543,11 +543,12 @@ describe('getDiff', () => { const reads = Array.from({ length: 8 }, () => getDiff('/repo', 'src/file.ts', true)) - await waitForMockCalls(gitExecFileAsyncBufferMock, 1) - expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(1) + // Why both up front: the two sides are independent spawns issued concurrently, + // so 8 identical reads still collapse to exactly 2 — one per side, not per read. + await waitForMockCalls(gitExecFileAsyncBufferMock, 2) + expect(gitExecFileAsyncBufferMock).toHaveBeenCalledTimes(2) leftBlob.resolve() - await waitForMockCalls(gitExecFileAsyncBufferMock, 2) rightBlob.resolve() const results = await Promise.all(reads) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 34c5d750476..1375e4dca87 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -1244,21 +1244,29 @@ async function loadDiff( let modifiedDeleted = false try { - const leftBlob = staged - ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) - : compareAgainstHead - ? await readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) - : await readUnstagedLeftBlob(worktreePath, filePath, options) - originalContent = leftBlob.content - originalIsBinary = leftBlob.isBinary - if (staged) { - const rightBlob = await readGitBlobAtIndexPath(worktreePath, filePath, options) + // Why concurrent: HEAD and the index are independent `git show` spawns. + // Only this branch qualifies — the unstaged left read chains index→HEAD. + const [leftBlob, rightBlob] = await Promise.all([ + readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options), + readGitBlobAtIndexPath(worktreePath, filePath, options) + ]) + originalContent = leftBlob.content + originalIsBinary = leftBlob.isBinary modifiedContent = rightBlob.content modifiedIsBinary = rightBlob.isBinary modifiedDeleted = !rightBlob.exists } else { - const workingTreeBlob = await readWorkingTreeFile(path.join(worktreePath, filePath)) + // The left chain (index→HEAD) is sequential within itself, but the working + // tree read is a plain fs read that does not depend on it. + const [leftBlob, workingTreeBlob] = await Promise.all([ + compareAgainstHead + ? readGitBlobAtOidPath(worktreePath, 'HEAD', filePath, options) + : readUnstagedLeftBlob(worktreePath, filePath, options), + readWorkingTreeFile(path.join(worktreePath, filePath)) + ]) + originalContent = leftBlob.content + originalIsBinary = leftBlob.isBinary modifiedContent = workingTreeBlob.content modifiedIsBinary = workingTreeBlob.isBinary modifiedDeleted = !workingTreeBlob.exists @@ -1397,8 +1405,12 @@ async function loadBranchDiff( ): Promise { try { const leftPath = args.oldPath ?? args.filePath - const leftBlob = await readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options) - const rightBlob = await readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) + // Why concurrent: the two sides are independent `git show` spawns, so awaiting + // them in series doubles the latency of every diff the review panel opens. + const [leftBlob, rightBlob] = await Promise.all([ + readGitBlobAtOidPath(worktreePath, args.mergeBase, leftPath, options), + readGitBlobAtOidPath(worktreePath, args.headOid, args.filePath, options) + ]) return buildDiffResult( leftBlob.content, @@ -1510,15 +1522,14 @@ async function loadCommitDiff( ): Promise { try { const leftPath = args.oldPath ?? args.filePath - const leftBlob = args.parentOid - ? await readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) - : { content: '', isBinary: false } - const rightBlob = await readGitBlobAtOidPath( - worktreePath, - args.commitOid, - args.filePath, - options - ) + // Why concurrent: the two sides are independent `git show` spawns. A root + // commit has no parent to read, so that side resolves without a spawn. + const [leftBlob, rightBlob] = await Promise.all([ + args.parentOid + ? readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath, options) + : Promise.resolve({ content: '', isBinary: false }), + readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath, options) + ]) return buildDiffResult( leftBlob.content, diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index c6f89509e16..02810726347 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -16,6 +16,7 @@ vi.mock('os', async () => { }) import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' +import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = @@ -97,7 +98,7 @@ describe('GrokHookService', () => { } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands // on the curl command line (EDR oversized-command-line false positive). - expect(script).toContain('payload=$(cat)') + expect(script).toContain(`payload=$(${POSIX_HOOK_STDIN_READER})`) expect(script).toContain('printf \'%s\' "$payload" | curl') expect(script).toContain('--data-urlencode "payload@-"') expect(script).toContain('${#GROK_HOME}" -le 4096') diff --git a/src/main/i18n/main-i18n-lazy-locale.test.ts b/src/main/i18n/main-i18n-lazy-locale.test.ts index 877c36c47e4..30d11f54977 100644 --- a/src/main/i18n/main-i18n-lazy-locale.test.ts +++ b/src/main/i18n/main-i18n-lazy-locale.test.ts @@ -17,11 +17,18 @@ import { UI_LANGUAGE_KOREAN, UI_LANGUAGE_SPANISH } from '../../shared/ui-language' -import { ensureMainI18n, setMainUiLanguage, translateMain } from './main-i18n' +import { + ensureMainI18n, + setMainPluginLanguagePacks, + setMainUiLanguage, + translateMain +} from './main-i18n' +import { pluginLanguageResourceId } from '../../shared/plugins/plugin-language-pack-artifact' describe('main-i18n lazy locale loading', () => { beforeEach(async () => { await ensureMainI18n() + setMainPluginLanguagePacks([]) await setMainUiLanguage(UI_LANGUAGE_ENGLISH) }) @@ -58,4 +65,24 @@ describe('main-i18n lazy locale loading', () => { await setMainUiLanguage(UI_LANGUAGE_ENGLISH) expect(translateMain('menu.file', 'File')).toBe('File') }) + + it('loads a contributed catalog for native menus and dialogs', async () => { + const id = 'plugin:orca-samples.portuguese/pt-BR' as const + setMainPluginLanguagePacks([ + { + id, + resourceLanguage: pluginLanguageResourceId(id), + pluginKey: 'orca-samples.portuguese', + locale: 'pt-BR', + catalog: { menu: { file: 'Arquivo Orca' } } + } + ]) + + await setMainUiLanguage(id) + expect(translateMain('menu.file', 'File')).toBe('Arquivo Orca') + + setMainPluginLanguagePacks([]) + expect(await setMainUiLanguage(id)).toBe('en') + expect(translateMain('menu.file', 'File')).toBe('File') + }) }) diff --git a/src/main/i18n/main-i18n.ts b/src/main/i18n/main-i18n.ts index 4904260db5c..8ebe21e0545 100644 --- a/src/main/i18n/main-i18n.ts +++ b/src/main/i18n/main-i18n.ts @@ -9,10 +9,13 @@ import i18next, { import { isPseudoLocalizationLocale, pseudoLocalizeString } from '../../shared/pseudo-localization' import { DEFAULT_UI_LOCALE, resolveUiLocale, type SupportedUiLocale } from '../../shared/ui-locale' import { UI_LANGUAGE_SYSTEM, type UiLanguage } from '../../shared/ui-language' +import type { PluginLanguagePackRegistration } from '../../shared/plugins/plugin-language-pack-artifact' export const mainI18n: I18nInstance = i18next.createInstance() let initialized = false +let pluginLanguagePacks: readonly PluginLanguagePackRegistration[] = [] +const registeredPluginLanguages = new Set() // Why: main-process callers pass English fallbacks to translateMain(), so the // main bundle does not need to parse any locale catalog at cold start. Only @@ -72,16 +75,20 @@ export async function ensureMainI18n(): Promise { } }) initialized = true + applyMainPluginLanguagePacks() } return mainI18n } -export async function setMainUiLanguage(language: UiLanguage): Promise { +export async function setMainUiLanguage(language: UiLanguage): Promise { await ensureMainI18n() - const locale = resolveUiLocale( + const selectedLocale = resolveUiLocale( language, language === UI_LANGUAGE_SYSTEM ? getMainSystemLocale() : DEFAULT_UI_LOCALE ) + const locale = + pluginLanguagePacks.find((pack) => pack.id === selectedLocale)?.resourceLanguage ?? + (selectedLocale.startsWith('plugin:') ? DEFAULT_UI_LOCALE : selectedLocale) if (mainI18n.language !== locale) { // changeLanguage triggers the lazy backend load for non-English locales and // resolves once the catalog is in memory, so callers that await this have @@ -91,6 +98,30 @@ export async function setMainUiLanguage(language: UiLanguage): Promise void) | null = null let watcherShutdownPromise: Promise | null = null let watcherShutdownDone = false let automations: AutomationService | null = null +let pluginService: PluginService | null = null +let pluginKillListService: PluginKillListService | null = null +let pluginMarketplaceService: PluginMarketplaceService | null = null +let pluginMarketplaceInstaller: PluginMarketplaceInstaller | null = null let keybindings: KeybindingService | null = null + +function emitPluginWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + pluginService?.emitEvent( + event.kind === 'created' ? 'worktree.created' : 'worktree.removed', + event.kind === 'created' + ? { worktreeId: event.worktreeId, path: event.path, branch: event.branch } + : { worktreeId: event.worktreeId, path: event.path } + ) +} // Why: a reload intent must not leak to a later load; the recovery reload re-fires did-finish-load, so its flag spares live PTYs from the orphan sweep (#5787). const expectedRendererReload = createWebContentsTimedFlag() const recoveryReloadInFlight = createWebContentsTimedFlag() @@ -666,10 +702,7 @@ if (hasSingleInstanceLock) { configureElectronNetworkCompatibility() enableRendererHeapHeadroom() maybeApplyGpuFallbackForThisLaunch() - if (gpuFallbackActiveThisLaunch) { - // Software fallback still shares the renderer's bounded Windows retention policy. - configureMainProcessWebglContextBudget() - } else { + if (!gpuFallbackActiveThisLaunch) { enableMainProcessGpuFeatures() } // Why: headless serve's offscreen BrowserWindows need an X display (Xvfb) on Linux; the result gates whether the offscreen backend is installed. @@ -853,10 +886,20 @@ async function prepareCodexSessionResumeForLaunch(args: { const sessionSource = await findTrustedCodexSessionResume({ sessionId: args.providerSession.id, transcriptPath: args.providerSession.transcriptPath, - trustedCodexHomes: trustedHomes + trustedCodexHomes: trustedHomes, + // Why: the legacy id rescan's winning home becomes this pane's CODEX_HOME, i.e. its account; + // rank it by the current selection so settings insertion order can never decide the account. + // Lazy: only the legacy branch ranks, so a provenance-present resume never stats the marker. + getSelectedAccountCodexHome: () => codexRuntimeHome!.getSelectedHostAccountCodexHomePath(), + systemCodexHomePath: systemHomePath, + // Why: the mirror winning is what triggers the migration into ~/.codex below, so it must + // outrank the path-sorted account homes or a system-default selection resumes as an account. + sharedRuntimeCodexHomePath: getOrcaManagedCodexHomePath() }) if (!sessionSource) { - if (args.providerSession.transcriptPath) { + // Why: an unverifiable Codex rollout still blocks; only paths that never claimed Codex + // provenance (e.g. ~/.claude/… on a pane mislabeled "codex") fall through to a normal launch. + if (claimsCodexRolloutLayout(args.providerSession.transcriptPath)) { throw new Error( 'Orca could not verify the originating Codex session file, so automatic resume was stopped to avoid using a different account.' ) @@ -1168,7 +1211,11 @@ function openMainWindow(): BrowserWindow { }, onOrcaProfileAuthMutation: () => desktopRelayService?.authMutated(), onBeforeOrcaProfileSignOut: () => desktopRelayService?.fenceAndCloseNow() - } + }, + pluginService ?? undefined, + pluginMarketplaceService && pluginMarketplaceInstaller + ? { marketplace: pluginMarketplaceService, installer: pluginMarketplaceInstaller } + : undefined ) automations.setWebContents(window.webContents) automations.start() @@ -1192,7 +1239,8 @@ function openMainWindow(): BrowserWindow { isRecoveryReloadInFlight, onBeforeUpdateQuit: () => preserveAgentAuthBeforeRestart({ codexRuntimeHome, claudeRuntimeAuth, store }), - updateInstallMode: resolveUpdateInstallMode(isServeMode) + updateInstallMode: resolveUpdateInstallMode(isServeMode), + onWorktreeLifecycle: emitPluginWorktreeLifecycle } ) rateLimits.attach(window) @@ -1558,6 +1606,9 @@ function getBundledWebClientRoot(): string | undefined { } async function renderTerminalPairingQr(pairingUrl: string): Promise { + // Why dynamic: qrcode is only reachable from mobile pairing, so launch should + // not parse it for the majority who never pair a device. + const QRCode = await import('qrcode') try { return await QRCode.toString(pairingUrl, { type: 'terminal', small: true }) } catch { @@ -1901,9 +1952,32 @@ app.whenReady().then(async () => { agentAwakeService.setEnabled(store.getSettings().keepComputerAwakeWhileAgentsRun) // Why: start from empty — disk-hydrated status rows are UI continuity only; only this runtime's hook events keep the computer awake. agentAwakeService.setStatuses([]) - unsubscribeAgentAwakeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { + const collectChangedProviderSessionWorktrees = createHookProviderSessionInvalidator() + const publishProviderSessionChanges = (identities: AgentHookProviderSessionIdentity[]): void => { + const ownedIdentities = identities.map((identity) => ({ + ...identity, + worktreeId: + identity.worktreeId ?? + runtime?.getTerminalWorktreeIdForPaneKey(identity.paneKey) ?? + undefined + })) + for (const worktreeId of collectChangedProviderSessionWorktrees(ownedIdentities)) { + runtime?.notifyMobileSessionTabsChanged(worktreeId) + } + } + const unsubscribeStatusChanges = agentHookServer.subscribeStatusChanges((statuses) => { agentAwakeService?.setStatuses(statuses) }) + const unsubscribeProviderSessionChanges = agentHookServer.subscribeProviderSessionChanges( + (sessions) => { + // Healthy session.tabs streams need a push when transcript identity changes. + publishProviderSessionChanges(sessions) + } + ) + unsubscribeAgentAwakeStatusChanges = () => { + unsubscribeStatusChanges() + unsubscribeProviderSessionChanges() + } // Why: telemetry must init before any IPC handler/renderer can call track(); it's a no-op in dev and while TELEMETRY_ENABLED is false, so it's safe early. initTelemetry(store) // Why: the trust-grant module is bundled into plain-node CLI entries where @@ -2050,6 +2124,27 @@ app.whenReady().then(async () => { .filter((account) => !activeIds.has(account.id)) .map((account) => ({ id: account.id, managedHomePath: account.managedHomePath })) }) + const orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport = { + resolve: (selector) => { + const environment = resolveEnvironment(app.getPath('userData'), selector) + const pairing = getPreferredPairingOffer(environment) + return { + environmentId: environment.id, + name: environment.name, + peerFingerprint: fingerprintOrchestrationPeer(pairing.publicKeyB64) + } + }, + call: (selector, method, params, timeoutMs, envelope) => + callRuntimeEnvironment( + app.getPath('userData'), + selector, + method, + params, + timeoutMs, + undefined, + envelope + ) + } const runtimeService = new OrcaRuntimeService(store, stats, { agentSessionClaimSigner: loadAgentSessionClaimSigner( getProfileUserDataPath(), @@ -2073,6 +2168,12 @@ app.whenReady().then(async () => { // Why: worktree.ps pulls hook-reported agent status (same source as the desktop sidebar) at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot().filter((entry) => entry.providerSessionOnly !== true), + // Why: the filter above hides resume-identity rows from the live-agent views, but + // those rows carry the provider session mobile native chat addresses transcripts + // by — Pi publishes identity that way and would otherwise be unreachable. + getAgentProviderSessionSnapshot: () => agentHookServer.getStatusSnapshot(), + getAgentProviderSessionRowsForPane: (paneKey) => + agentHookServer.getStatusSnapshotForPane(paneKey), // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. getAdditionalAiVaultCodexHomePaths: () => codexRuntimeHome ? codexRuntimeHome.getHostCodexHomePathsForSessionDiscovery() : [], @@ -2082,9 +2183,11 @@ app.whenReady().then(async () => { systemCodexHomePath: resolveHostCodexSessionSourceHome(store!.getSettings()) }), buildAgentHookPtyEnv: () => - isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {} + isAgentStatusHooksEnabled(store?.getSettings()) ? agentHookServer.buildPtyEnv() : {}, + orchestrationEnvironmentTransport }) runtime = runtimeService + publishProviderSessionChanges(agentHookServer.getProviderSessionIdentities()) browserManager.setBrowserGuestStateChangedListener((worktreeId) => { runtimeService.notifyMobileSessionTabsChanged(worktreeId) }) @@ -2187,6 +2290,137 @@ app.whenReady().then(async () => { prepareForCodexLaunch: prepareCodexRuntimeHomeForLaunch, prepareForClaudeLaunch: (target) => claudeRuntimeAuth!.prepareForClaudeLaunch(target) }) + const pluginSystemStartupStartedAt = performance.now() + pluginKillListService = new PluginKillListService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')) + }) + await pluginKillListService.initialize() + pluginMarketplaceService = new PluginMarketplaceService({ + pluginsDataDir: getPluginsDataDir(app.getPath('userData')), + getKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null + }) + const requestOfficialMarketplaceSeed = (): void => { + if (store?.getSettings().pluginSystemEnabled !== true) { + return + } + void pluginMarketplaceService?.seedOfficialSource().catch((error) => { + console.warn('[plugins] failed to configure the official marketplace:', error) + }) + } + pluginMarketplaceInstaller = new PluginMarketplaceInstaller({ + marketplace: pluginMarketplaceService, + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null + }) + pluginService = new PluginService({ + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + // Feature flag: with the setting off, discovery returns nothing and no + // plugin code path runs at all. + isPluginSystemEnabled: () => store?.getSettings().pluginSystemEnabled === true, + getDisabledPlugins: () => normalizePluginIdList(store?.getSettings().disabledPlugins), + getPluginConsents: () => normalizePluginConsents(store?.getSettings().pluginConsents), + getDevPluginPaths: () => normalizePluginIdList(store?.getSettings().devPluginPaths), + getKeybindings: () => keybindings?.getOverrides() ?? {}, + getPluginKillListEntry: (pluginKey) => pluginKillListService?.find(pluginKey) ?? null, + hostEntryPath: resolvePluginHostEntryPath(app.getAppPath(), app.isPackaged) + }) + const bundledPluginBootstrap = new PluginBundledBootstrapCoordinator({ + root: resolveBundledPluginRoot({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath() + }), + userDataPath: app.getPath('userData'), + hostVersion: app.getVersion(), + isEnabled: () => store?.getSettings().pluginSystemEnabled === true, + blockedPluginReason: (pluginKey) => pluginKillListService?.reason(pluginKey) ?? null, + refreshPlugins: () => pluginService?.refresh() ?? Promise.resolve() + }) + const requestBundledPluginBootstrap = (): void => { + void bundledPluginBootstrap + .request() + .then((result) => { + for (const failure of result?.errors ?? []) { + console.warn(`[plugins] failed to publish bundled ${failure.pluginKey}:`, failure.error) + } + }) + .catch((error) => { + console.warn('[plugins] failed to bootstrap bundled plugins:', error) + }) + } + pluginKillListService.onChanged(() => { + void pluginService?.reconcileActivationState().catch((error) => { + console.warn('[plugins] failed to apply plugin safety-list refresh:', error) + }) + }) + store.onSettingsChanged((updates) => { + if (updates.pluginSystemEnabled === true) { + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + } + if (app.isPackaged && updates.pluginSystemEnabled === true) { + void pluginKillListService?.refresh().catch((error) => { + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + }) + } + }) + // Why: headless `orca serve` clients reach plugins through the runtime RPC + // methods, which resolve the service via this module-level setter. Consent + // over RPC uses the same hash-keyed write path as the desktop dialog. + setPluginServiceForRpc(pluginService, { + applyConsent: (request) => + applyPluginConsent({ store: store!, pluginService: pluginService!, ...request }), + applyEnablement: (pluginKey, enabled) => + applyPluginEnablement({ store: store!, pluginService: pluginService!, pluginKey, enabled }) + }) + // Lazy kernel: initialize() only discovers manifests — no worker forks, no + // panel reads. Zero plugin code runs before an explicit trigger. + void pluginService + .initialize() + .then(() => { + logStartupMilestone('plugin-system-initialized', { + durationMs: Number((performance.now() - pluginSystemStartupStartedAt).toFixed(2)), + installedPlugins: pluginService?.getDiscovered().length ?? 0 + }) + }) + .catch((error) => { + console.warn('[plugins] failed to initialize plugin service:', error) + }) + if (app.isPackaged && store?.getSettings().pluginSystemEnabled === true) { + void pluginKillListService.refresh().catch((error) => { + console.warn('[plugins] failed to refresh plugin safety list; using cached state:', error) + }) + } + pluginService.onChanged((event) => { + if ( + event.contentPacksChanged && + setMainPluginLanguagePacks(pluginService?.contentPacks.languagePacks.list() ?? []) + ) { + void setMainUiLanguage(store!.getSettings().uiLanguage).then(() => rebuildAppMenu()) + } + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.webContents.send('plugins:changed', event) + } + } + }) + requestBundledPluginBootstrap() + requestOfficialMarketplaceSeed() + // v0 plugin event seams: agent status (hook pipeline tap) + worktree + // lifecycle (runtime tap). Server-side filtered per plugin subscription. + agentHookServer.subscribeEnrichedStatus((enriched) => { + pluginService?.emitEvent('agent.status.changed', { + worktreeId: enriched.worktreeId ?? null, + paneKey: enriched.paneKey, + state: enriched.payload.state, + receivedAt: enriched.receivedAt + }) + }) + runtimeService.onWorktreeLifecycle((event) => { + emitPluginWorktreeLifecycle(event) + }) starNag = new StarNagService(store, stats) starNag.start() starNag.registerIpcHandlers() @@ -2535,6 +2769,16 @@ app.on('will-quit', (e) => { // Why: stats.flush() must precede killAllPty() so still-running agents emit synthetic agent_stop events (killAllPty skips runtime.onPtyExit()). starNag?.stop() automations?.stop() + // Why: plugin hosts are forked children; dispose sends shutdown and + // escalates to SIGKILL so they cannot outlive the app. The promise joins + // the teardown barrier below — quitting before it resolves would let + // Electron exit first and orphan the hosts. + setPluginServiceForRpc(null) + pluginKillListService = null + pluginMarketplaceService = null + pluginMarketplaceInstaller = null + const pluginHostShutdown = pluginService?.dispose() ?? Promise.resolve() + pluginService = null setUnreadDockBadgeCount(0) agentHookServer.stop() // Why: cancels relay restart/reinstall timers and kills wsl.exe children deterministically, not via stdio-pipe teardown. @@ -2581,7 +2825,8 @@ app.on('will-quit', (e) => { { name: 'daemon', promise: daemonTeardown }, { name: 'runtime-rpc', promise: rpcStopAndClear }, { name: 'watchers', promise: watcherShutdown }, - { name: 'emulator', promise: emulatorShutdown } + { name: 'emulator', promise: emulatorShutdown }, + { name: 'plugin-hosts', promise: pluginHostShutdown } ]) .then((pendingTeardowns) => { if (pendingTeardowns.length > 0) { diff --git a/src/main/ipc/ephemeral-vm-recipe-context.ts b/src/main/ipc/ephemeral-vm-recipe-context.ts index aba6c730755..c922d6f13b1 100644 --- a/src/main/ipc/ephemeral-vm-recipe-context.ts +++ b/src/main/ipc/ephemeral-vm-recipe-context.ts @@ -26,7 +26,11 @@ export type RecipeRepoResult = | { ok: true; repo: Exclude, null | undefined> } | { ok: false; message: string; doctor: (recipeId: string) => EphemeralVmRecipeDoctorResult } -export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeListResult { +export function listRecipes( + store: Store, + repoId: string, + pluginRecipes: readonly OrcaVmRecipe[] = [] +): EphemeralVmRecipeListResult { const repo = store.getRepo(repoId) if (!repo || isFolderRepo(repo)) { return { @@ -50,12 +54,15 @@ export function listRecipes(store: Store, repoId: string): EphemeralVmRecipeList return { status: 'ok', repoPath: repo.path, - recipes: hooks?.environmentRecipes ?? [], + recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes), diagnostics: hooks?.environmentRecipeDiagnostics ?? [] } } -export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[] { +export function listRecipeCatalog( + store: Store, + pluginRecipes: readonly OrcaVmRecipe[] = [] +): EphemeralVmRecipeCatalogEntry[] { return store .getRepos() .filter((repo) => isGitRepoKind(repo) && !isFolderRepo(repo) && !repo.connectionId) @@ -65,7 +72,7 @@ export function listRecipeCatalog(store: Store): EphemeralVmRecipeCatalogEntry[] repoId: repo.id, repoName: repo.displayName, repoPath: repo.path, - recipes: hooks?.environmentRecipes ?? [], + recipes: combineEphemeralVmRecipes(hooks?.environmentRecipes ?? [], pluginRecipes), diagnostics: hooks?.environmentRecipeDiagnostics ?? [] } }) @@ -103,15 +110,41 @@ export function getRuntimeRecipeContext( if (!repo.ok) { throw new Error(repo.message) } - const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( - (entry) => entry.id === runtime.recipeId - ) + // Pre-snapshot runtimes can only be attributed to repo-owned recipes. Never + // substitute a later same-id plugin recipe for an older runtime lifecycle. + const recipe = + runtime.recipe ?? + (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( + (entry) => entry.id === runtime.recipeId + ) if (!recipe) { throw new Error(`Recipe not found: ${runtime.recipeId}`) } return { runtime, repo, recipe } } +export function resolveRecipeForRepo( + repoPath: string, + recipeId: string, + pluginRecipes: readonly OrcaVmRecipe[] = [] +): OrcaVmRecipe | null { + return ( + combineEphemeralVmRecipes(loadHooks(repoPath)?.environmentRecipes ?? [], pluginRecipes).find( + (recipe) => recipe.id === recipeId + ) ?? null + ) +} + +/** Project-owned recipes are authoritative for their repository and shadow + * same-id global plugin recipes without disabling the rest of the pack. */ +export function combineEphemeralVmRecipes( + repoRecipes: readonly OrcaVmRecipe[], + pluginRecipes: readonly OrcaVmRecipe[] +): OrcaVmRecipe[] { + const repoIds = new Set(repoRecipes.map((recipe) => recipe.id)) + return [...repoRecipes, ...pluginRecipes.filter((recipe) => !repoIds.has(recipe.id))] +} + function failedRecipeRepo(repoPath: string | null, message: string): RecipeRepoResult { return { ok: false, diff --git a/src/main/ipc/ephemeral-vm-runtime-handlers.ts b/src/main/ipc/ephemeral-vm-runtime-handlers.ts index f3ce3caec94..dea6d811706 100644 --- a/src/main/ipc/ephemeral-vm-runtime-handlers.ts +++ b/src/main/ipc/ephemeral-vm-runtime-handlers.ts @@ -1,6 +1,5 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' -import { loadHooks } from '../hooks' import { listEphemeralVmRuntimes, updateEphemeralVmRuntimeStatus @@ -28,7 +27,7 @@ import { disconnectRuntimeOwnedSshTarget, removeRuntimeOwnedSshTarget } from '../ephemeral-vm-runtime-ssh' -import { getRecipeRepo, getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' +import { getRuntimeRecipeContext } from './ephemeral-vm-recipe-context' import { invalidateRuntimeEnvironmentTransport } from './runtime-environments' export type EphemeralVmCleanupCommandResult = { @@ -74,30 +73,21 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { if (!runtime.repoId) { throw new Error(`Ephemeral VM runtime has no repo id: ${args.runtimeId}`) } - const repo = getRecipeRepo(store, runtime.repoId) - if (!repo.ok) { + let resolved: ReturnType + try { + resolved = getRuntimeRecipeContext(store, userDataPath, runtime.id) + } catch (error) { return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, { status: 'cleanup_failed', cleanupStatus: 'failed', cleanupLastAttemptAt: Date.now(), - cleanupLastError: repo.message - }) - } - const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( - (entry) => entry.id === runtime.recipeId - ) - if (!recipe) { - return updateEphemeralVmRuntimeStatus(userDataPath, runtime.id, { - status: 'cleanup_failed', - cleanupStatus: 'failed', - cleanupLastAttemptAt: Date.now(), - cleanupLastError: `Recipe not found: ${runtime.recipeId}` + cleanupLastError: error instanceof Error ? error.message : String(error) }) } const result = await cleanupEphemeralVmRuntime({ userDataPath, - repoPath: repo.repo.path, - recipe, + repoPath: resolved.repo.repo.path, + recipe: resolved.recipe, runtimeId: runtime.id }) if (result.ok && runtime.runtimeEnvironmentId) { @@ -217,7 +207,7 @@ export function registerEphemeralVmRuntimeHandlers(store: Store): void { ipcMain.handle( 'ephemeralVm:getCleanupCommand', - (_event, args: { runtimeId: string }): EphemeralVmCleanupCommandResult => { + async (_event, args: { runtimeId: string }): Promise => { const userDataPath = app.getPath('userData') const resolved = getRuntimeRecipeContext(store, userDataPath, args.runtimeId) const payload = buildEphemeralVmRecipeCleanupPayload({ diff --git a/src/main/ipc/ephemeral-vm.test.ts b/src/main/ipc/ephemeral-vm.test.ts index 45f980bad9a..8df189a4abb 100644 --- a/src/main/ipc/ephemeral-vm.test.ts +++ b/src/main/ipc/ephemeral-vm.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' import { listEnvironments } from '../../shared/runtime-environment-store' +import { upsertEphemeralVmRuntime } from '../../shared/ephemeral-vm-runtime-store' const handlers = new Map Promise | unknown>() const { @@ -92,6 +93,15 @@ function nodeCommand(scriptPath: string): string { return `"${process.execPath}" "${scriptPath}"` } +function pluginServiceWithRecipes( + recipes: { pluginKey: string; recipe: Record }[] +) { + return { + whenReady: vi.fn().mockResolvedValue(undefined), + contentPacks: { vmRecipes: { list: vi.fn(() => recipes) } } + } +} + describe('registerEphemeralVmHandlers', () => { const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') @@ -198,6 +208,132 @@ describe('registerEphemeralVmHandlers', () => { ]) }) + it('merges approved plugin recipes while repository recipes shadow matching ids', async () => { + const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') + writeFileSync( + join(repoPath, 'orca.yaml'), + [ + 'environmentRecipes:', + ' - id: shared', + ' name: Repository Recipe', + ' create: repo-create' + ].join('\n') + ) + const pluginService = pluginServiceWithRecipes([ + { + pluginKey: 'orca-samples.recipes', + recipe: { id: 'shared', name: 'Plugin Shared', create: 'plugin-shared' } + }, + { + pluginKey: 'orca-samples.recipes', + recipe: { id: 'global', name: 'Plugin Global', create: 'plugin-global' } + } + ]) + + registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never) + const result = (await handlers.get('ephemeralVm:listRecipes')?.(null, { + repoId: 'repo-1' + } as never)) as { recipes: { id: string; name: string }[] } + + expect(pluginService.whenReady).toHaveBeenCalled() + expect(result.recipes).toMatchObject([ + { id: 'shared', name: 'Repository Recipe' }, + { id: 'global', name: 'Plugin Global' } + ]) + }) + + it('uses an immutable plugin recipe snapshot after the plugin is removed', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') + getPathMock.mockReturnValue(userDataPath) + const startPath = join(repoPath, 'start.js') + const destroyPath = join(repoPath, 'destroy.js') + writeFileSync( + startPath, + `console.log(${JSON.stringify( + JSON.stringify({ + schemaVersion: 1, + pairingCode: makePairingCode(), + projectRoot: '/workspace/repo' + }) + )})` + ) + writeFileSync(destroyPath, "require('fs').writeFileSync('plugin-cleaned.txt', 'yes')") + const registrations = [ + { + pluginKey: 'orca-samples.recipes', + recipe: { + id: 'plugin-cloud', + name: 'Plugin Cloud', + create: nodeCommand(startPath), + destroy: nodeCommand(destroyPath) + } + } + ] + const pluginService = pluginServiceWithRecipes(registrations) + registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never) + + const provisioned = (await handlers.get('ephemeralVm:provision')?.(null, { + repoId: 'repo-1', + recipeId: 'plugin-cloud' + } as never)) as { ok: true; runtime: { id: string; recipe?: { id: string } } } + registrations.splice(0) + const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, { + runtimeId: provisioned.runtime.id + } as never) + + expect(provisioned.runtime.recipe).toMatchObject({ id: 'plugin-cloud' }) + expect(cleaned).toEqual(expect.objectContaining({ status: 'cleaned' })) + expect(readFileSync(join(repoPath, 'plugin-cleaned.txt'), 'utf8')).toBe('yes') + }) + + it('never substitutes a later same-id plugin recipe for a legacy runtime', async () => { + const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-') + const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') + getPathMock.mockReturnValue(userDataPath) + const pluginDestroyPath = join(repoPath, 'plugin-destroy.js') + writeFileSync( + pluginDestroyPath, + "require('fs').writeFileSync('plugin-destroy-ran.txt', 'unsafe')" + ) + upsertEphemeralVmRuntime(userDataPath, { + id: 'legacy-runtime', + recipeId: 'shared-id', + repoId: 'repo-1', + status: 'running', + cleanupStatus: 'not_started', + createdAt: 1, + updatedAt: 1, + recipeResult: { + schemaVersion: 1, + pairingCode: makePairingCode(), + projectRoot: '/workspace/repo' + } + }) + const pluginService = pluginServiceWithRecipes([ + { + pluginKey: 'orca-samples.recipes', + recipe: { + id: 'shared-id', + name: 'Later Plugin Recipe', + create: 'create', + destroy: nodeCommand(pluginDestroyPath) + } + } + ]) + registerEphemeralVmHandlers(makeStore(repoPath) as never, pluginService as never) + + const cleaned = await handlers.get('ephemeralVm:cleanup')?.(null, { + runtimeId: 'legacy-runtime' + } as never) + + expect(cleaned).toMatchObject({ + status: 'cleanup_failed', + cleanupLastError: 'Recipe not found: shared-id' + }) + expect(existsSync(join(repoPath, 'plugin-destroy-ran.txt'))).toBe(false) + }) + it('provisions a recipe and persists the ephemeral runtime', async () => { const userDataPath = makeDir('orca-ephemeral-vm-ipc-user-data-') const repoPath = makeDir('orca-ephemeral-vm-ipc-repo-') diff --git a/src/main/ipc/ephemeral-vm.ts b/src/main/ipc/ephemeral-vm.ts index 9d071989c85..f75c9c77d39 100644 --- a/src/main/ipc/ephemeral-vm.ts +++ b/src/main/ipc/ephemeral-vm.ts @@ -1,6 +1,5 @@ import { app, ipcMain } from 'electron' import type { Store } from '../persistence' -import { loadHooks } from '../hooks' import { getEphemeralVmRecipeResultConnection, getEphemeralVmRecipeResultWarnings, @@ -27,9 +26,12 @@ import { getRecipeRepo, listRecipeCatalog, listRecipes, + resolveRecipeForRepo, type EphemeralVmRecipeCatalogEntry } from './ephemeral-vm-recipe-context' import { registerEphemeralVmRuntimeHandlers } from './ephemeral-vm-runtime-handlers' +import type { PluginService } from '../plugins/plugin-service' +import { getApprovedPluginVmRecipes } from '../plugins/plugin-approved-vm-recipes' const activeProvisionControllers = new Map() @@ -57,7 +59,7 @@ export type EphemeralVmProvisionIpcResult = stdout: string } -export function registerEphemeralVmHandlers(store: Store): void { +export function registerEphemeralVmHandlers(store: Store, pluginService?: PluginService): void { ipcMain.removeHandler('ephemeralVm:listRecipes') ipcMain.removeHandler('ephemeralVm:listRecipeCatalog') ipcMain.removeHandler('ephemeralVm:doctor') @@ -65,25 +67,32 @@ export function registerEphemeralVmHandlers(store: Store): void { ipcMain.removeHandler('ephemeralVm:cancelProvision') registerEphemeralVmRuntimeHandlers(store) - ipcMain.handle('ephemeralVm:listRecipes', (_event, args: { repoId: string }) => { - return listRecipes(store, args.repoId) - }) - - ipcMain.handle('ephemeralVm:listRecipeCatalog', (): EphemeralVmRecipeCatalogEntry[] => { - return listRecipeCatalog(store) + ipcMain.handle('ephemeralVm:listRecipes', async (_event, args: { repoId: string }) => { + return listRecipes(store, args.repoId, await getApprovedPluginVmRecipes(pluginService)) }) + ipcMain.handle( + 'ephemeralVm:listRecipeCatalog', + async (): Promise => { + return listRecipeCatalog(store, await getApprovedPluginVmRecipes(pluginService)) + } + ) + ipcMain.handle( 'ephemeralVm:doctor', - (_event, args: { repoId: string; recipeId: string }): EphemeralVmRecipeDoctorResult => { + async ( + _event, + args: { repoId: string; recipeId: string } + ): Promise => { const repo = getRecipeRepo(store, args.repoId) if (!repo.ok) { return repo.doctor(args.recipeId) } + const pluginRecipes = await getApprovedPluginVmRecipes(pluginService) return doctorEphemeralVmRecipe({ repoPath: repo.repo.path, recipeId: args.recipeId, - recipes: loadHooks(repo.repo.path)?.environmentRecipes ?? [], + recipes: listRecipes(store, args.repoId, pluginRecipes).recipes, localExecutionSupported: true }) } @@ -106,8 +115,10 @@ export function registerEphemeralVmHandlers(store: Store): void { if (!repo.ok) { return { ok: false, error: repo.message, stdout: '', stderr: '' } } - const recipe = (loadHooks(repo.repo.path)?.environmentRecipes ?? []).find( - (entry) => entry.id === args.recipeId + const recipe = resolveRecipeForRepo( + repo.repo.path, + args.recipeId, + await getApprovedPluginVmRecipes(pluginService) ) if (!recipe) { return { ok: false, error: `Recipe not found: ${args.recipeId}`, stdout: '', stderr: '' } diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 4c8333d508e..5b1d1347a15 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -39,7 +39,7 @@ function snapshotFrame(entries: [type: string, mtime: string, path: string][]): .join('')}${SNAPSHOT_END}` } -type ScheduleBatchFlush = (rootKey: string, root: WatchedRoot) => void +type ScheduleBatchFlush = (root: WatchedRoot) => void type ScheduleBatchFlushMock = ReturnType> function makeDeps( diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index b73f58d5122..49ff8f20c7d 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -29,12 +29,14 @@ export type WatchedRoot = { subscription: WatcherSubscription listeners: Map batch: DebouncedBatch - rootPath?: string + // Why: the real on-disk path. Never substitute the watcher's rootKey — that is + // a comparison key (case/Unicode folded) and would reach the renderer as a path. + rootPath: string } export type WslWatcherDeps = { ignoreDirs: string[] - scheduleBatchFlush: (rootKey: string, root: WatchedRoot) => void + scheduleBatchFlush: (root: WatchedRoot) => void watchedRoots: Map } @@ -192,7 +194,7 @@ export async function createWslWatcher( } stopped = true markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) deps.watchedRoots.delete(rootKey) } @@ -208,7 +210,7 @@ export async function createWslWatcher( if (events.length > 0) { queueWatcherEvents(root.batch, events) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) } } @@ -227,7 +229,7 @@ export async function createWslWatcher( if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) { streamBuffer = '' markOverflowWithoutUncStat(root) - deps.scheduleBatchFlush(rootKey, root) + deps.scheduleBatchFlush(root) } return } diff --git a/src/main/ipc/filesystem-watcher.test.ts b/src/main/ipc/filesystem-watcher.test.ts index 4687a6b71e1..b2f83baaf22 100644 --- a/src/main/ipc/filesystem-watcher.test.ts +++ b/src/main/ipc/filesystem-watcher.test.ts @@ -118,7 +118,7 @@ describe('registerFilesystemWatcherHandlers', () => { const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () => reserveWatcherChild() ) - vi.mocked(createWslWatcher).mockImplementation(async () => { + vi.mocked(createWslWatcher).mockImplementation(async (_rootKey, worktreePath) => { const release = reserveWatcherChild() if (!release) { throw new WatcherChildCapacityError() @@ -126,7 +126,8 @@ describe('registerFilesystemWatcherHandlers', () => { return { subscription: { unsubscribe: vi.fn(async () => release()) }, listeners: new Map(), - batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 } + batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }, + rootPath: worktreePath } }) const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index b217f51e5b4..a4f13ff82ff 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -274,8 +274,8 @@ async function tryStatIsDirectory(filePath: string): Promise { +async function flushBatch(root: WatchedRoot): Promise { const overflowed = root.batch.overflowed const rawEvents = root.batch.events.splice(0) root.batch.overflowed = false @@ -300,7 +300,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) { // Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh. - emitOverflowPayload(rootKey, root) + emitOverflowPayload(root) return } @@ -320,7 +320,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { ) const payload: FsChangedPayload = { - worktreePath: root.rootPath ?? rootKey, + worktreePath: root.rootPath, events } @@ -331,7 +331,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise { } } -function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { +function scheduleBatchFlush(root: WatchedRoot): void { const now = Date.now() if (root.batch.firstEventAt === 0) { @@ -343,7 +343,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - void flushBatch(rootKey, root) + void flushBatch(root) return } @@ -351,7 +351,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void { if (root.batch.timer) { clearTimeout(root.batch.timer) } - root.batch.timer = setTimeout(() => void flushBatch(rootKey, root), DEBOUNCE_TRAILING_MS) + root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS) } // ── Watcher creation ───────────────────────────────────────────────── @@ -380,7 +380,7 @@ async function createWatcher( const markWatcherInterrupted = (): void => { root.batch.overflowed = true - scheduleBatchFlush(rootKey, root) + scheduleBatchFlush(root) } // Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap. @@ -390,7 +390,7 @@ async function createWatcher( if (err) { // Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3). console.error(`[filesystem-watcher] error for ${rootKey}:`, err) - emitOverflowPayload(rootKey, root) + emitOverflowPayload(root) // Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3). if (root.batch.timer) { clearTimeout(root.batch.timer) @@ -406,7 +406,7 @@ async function createWatcher( } queueWatcherEvents(root.batch, events) - scheduleBatchFlush(rootKey, root) + scheduleBatchFlush(root) }, watcherOptions, { diff --git a/src/main/ipc/keybindings.test.ts b/src/main/ipc/keybindings.test.ts index 3f0aa37aa28..ec8016287d5 100644 --- a/src/main/ipc/keybindings.test.ts +++ b/src/main/ipc/keybindings.test.ts @@ -75,6 +75,23 @@ describe('registerKeybindingHandlers', () => { expect(authorizeExternalPathMock).toHaveBeenCalledWith(snapshot.path) }) + it('reconciles plugin command conflicts after a shortcut edit', () => { + const onChanged = vi.fn() + const setActionBindings = vi.fn(() => snapshot) + registerKeybindingHandlers({ setActionBindings } as never, onChanged) + + expect( + getHandler('keybindings:setAction')( + {}, + { + actionId: 'plugin:orca-samples.tasks/open', + bindings: ['Mod+Shift+T'] + } + ) + ).toBe(snapshot) + expect(onChanged).toHaveBeenCalledOnce() + }) + it('authorizes the keybindings file before opening it outside Orca', async () => { openPathMock.mockResolvedValue('') registerKeybindingHandlers({ ensureFile: vi.fn(() => snapshot) } as never) diff --git a/src/main/ipc/keybindings.ts b/src/main/ipc/keybindings.ts index ae73c2b15ac..7a71f3116c0 100644 --- a/src/main/ipc/keybindings.ts +++ b/src/main/ipc/keybindings.ts @@ -13,7 +13,10 @@ function broadcastKeybindingsChanged(snapshot: KeybindingFileSnapshot): void { rebuildAppMenu() } -export function registerKeybindingHandlers(service: KeybindingService): void { +export function registerKeybindingHandlers( + service: KeybindingService, + onChanged?: () => void +): void { ipcMain.handle('keybindings:get', () => service.getSnapshot()) ipcMain.handle('keybindings:ensureFile', () => { @@ -22,6 +25,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void { // workspace. Opening it in the editor still needs normal fs IPC access. authorizeExternalPath(snapshot.path) broadcastKeybindingsChanged(snapshot) + onChanged?.() return snapshot }) @@ -30,6 +34,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void { (_event, args: { actionId: KeybindingActionId; bindings: string[] | null }) => { const snapshot = service.setActionBindings(args.actionId, args.bindings) broadcastKeybindingsChanged(snapshot) + onChanged?.() return snapshot } ) @@ -37,6 +42,7 @@ export function registerKeybindingHandlers(service: KeybindingService): void { ipcMain.handle('keybindings:reload', () => { const snapshot = service.reload() broadcastKeybindingsChanged(snapshot) + onChanged?.() return snapshot }) diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index 3482553bde1..fd46171a032 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -1,6 +1,5 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import { networkInterfaces } from 'node:os' -import QRCode from 'qrcode' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import { isTailnetIPv4Address } from '../../shared/tailnet-address' @@ -137,6 +136,9 @@ export function registerMobileHandlers( return { available: false as const } } + // Why dynamic: pairing is the only consumer, so launch should not parse + // the qrcode bundle for users who never pair a device. + const { default: QRCode } = await import('qrcode') const qrDataUrl = await QRCode.toDataURL(offer.pairingUrl, { errorCorrectionLevel: 'M', margin: 2, diff --git a/src/main/ipc/plugin-marketplaces.test.ts b/src/main/ipc/plugin-marketplaces.test.ts new file mode 100644 index 00000000000..b1badba09c4 --- /dev/null +++ b/src/main/ipc/plugin-marketplaces.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import type { PluginService } from '../plugins/plugin-service' + +type IpcHandler = (event: unknown, args?: unknown) => unknown + +const electronMocks = vi.hoisted(() => ({ handle: vi.fn() })) +vi.mock('electron', () => ({ ipcMain: { handle: electronMocks.handle } })) + +import { + registerPluginMarketplaceHandlers, + type PluginMarketplaceHandlerServices +} from './plugin-marketplaces' + +const SOURCE_ID = 'a'.repeat(32) +const MARKETPLACE_COMMIT = 'b'.repeat(40) +const PLUGIN_COMMIT = 'c'.repeat(40) +const PLUGIN_KEY = 'orca-samples.demo' + +let handlers: Map + +function createServices(): PluginMarketplaceHandlerServices { + return { + marketplace: { + listSources: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), + addSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), + removeSource: vi.fn().mockResolvedValue(true), + refreshSource: vi.fn().mockResolvedValue({ id: SOURCE_ID }), + refreshAll: vi.fn().mockResolvedValue([{ id: SOURCE_ID }]), + listPlugins: vi.fn().mockResolvedValue([{ pluginKey: PLUGIN_KEY }]) + } as unknown as PluginMarketplaceService, + installer: { + preview: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }), + install: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY }), + previewInstalledUpdate: vi.fn().mockResolvedValue({ pluginKey: PLUGIN_KEY }), + rollback: vi.fn().mockResolvedValue({ ok: true, pluginKey: PLUGIN_KEY }) + } as unknown as PluginMarketplaceInstaller + } +} + +function createPluginService(): PluginService { + return { + deactivatePlugin: vi.fn().mockResolvedValue(undefined), + refresh: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginService +} + +async function invoke(channel: string, args?: unknown): Promise { + const handler = handlers.get(channel) + if (!handler) { + throw new Error(`missing IPC handler: ${channel}`) + } + return handler({}, args) +} + +beforeEach(() => { + handlers = new Map() + electronMocks.handle.mockReset() + electronMocks.handle.mockImplementation((channel: string, handler: IpcHandler) => { + handlers.set(channel, handler) + }) +}) + +describe('plugin marketplace IPC authority', () => { + it('validates every mutating or plugin-selecting request strictly', async () => { + registerPluginMarketplaceHandlers(createPluginService(), createServices()) + + await expect( + invoke('plugins:addMarketplace', { + kind: 'git', + url: 'https://example.com/marketplace.git', + ref: 'main', + unexpected: true + }) + ).rejects.toThrow() + await expect( + invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID, unexpected: true }) + ).rejects.toThrow() + await expect( + invoke('plugins:refreshMarketplaces', { sourceId: 'not-a-source' }) + ).rejects.toThrow() + await expect( + invoke('plugins:previewMarketplacePlugin', { + marketplaceSourceId: SOURCE_ID, + pluginKey: '__proto__.demo' + }) + ).rejects.toThrow() + await expect( + invoke('plugins:installMarketplacePlugin', { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: 'moving-ref', + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + }) + ).rejects.toThrow() + await expect( + invoke('plugins:previewMarketplaceUpdate', { + pluginKey: PLUGIN_KEY, + unexpected: true + }) + ).rejects.toThrow() + await expect( + invoke('plugins:rollbackMarketplacePlugin', { pluginKey: 'bare-id' }) + ).rejects.toThrow() + }) + + it('dispatches source listing, add, removal, and refresh operations', async () => { + const services = createServices() + registerPluginMarketplaceHandlers(createPluginService(), services) + const source = { + kind: 'git' as const, + url: 'https://example.com/marketplace.git', + ref: 'main' + } + + await invoke('plugins:listMarketplaces') + await invoke('plugins:addMarketplace', source) + await invoke('plugins:removeMarketplace', { sourceId: SOURCE_ID }) + await invoke('plugins:refreshMarketplaces', { sourceId: SOURCE_ID }) + await invoke('plugins:refreshMarketplaces', {}) + await invoke('plugins:listMarketplacePlugins') + + expect(services.marketplace.listSources).toHaveBeenCalledTimes(2) + expect(services.marketplace.addSource).toHaveBeenCalledWith(source) + expect(services.marketplace.removeSource).toHaveBeenCalledWith(SOURCE_ID) + expect(services.marketplace.refreshSource).toHaveBeenCalledWith(SOURCE_ID) + expect(services.marketplace.refreshAll).toHaveBeenCalledOnce() + expect(services.marketplace.listPlugins).toHaveBeenCalledOnce() + }) + + it('refreshes discovery only after a successful install', async () => { + const services = createServices() + const pluginService = createPluginService() + registerPluginMarketplaceHandlers(pluginService, services) + const preview = { + marketplaceSourceId: SOURCE_ID, + marketplaceCommit: MARKETPLACE_COMMIT, + pluginKey: PLUGIN_KEY, + resolvedCommit: PLUGIN_COMMIT + } + + await invoke('plugins:installMarketplacePlugin', preview) + expect(services.installer.install).toHaveBeenCalledWith(preview) + expect(pluginService.refresh).toHaveBeenCalledOnce() + + vi.mocked(pluginService.refresh).mockClear() + vi.mocked(services.installer.install).mockResolvedValueOnce({ ok: false, error: 'failed' }) + await invoke('plugins:installMarketplacePlugin', preview) + expect(pluginService.refresh).not.toHaveBeenCalled() + }) + + it('deactivates before rollback and refreshes discovery only on success', async () => { + const services = createServices() + const pluginService = createPluginService() + registerPluginMarketplaceHandlers(pluginService, services) + + await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) + + expect(pluginService.deactivatePlugin).toHaveBeenCalledWith(PLUGIN_KEY) + expect(services.installer.rollback).toHaveBeenCalledWith(PLUGIN_KEY) + expect(vi.mocked(pluginService.deactivatePlugin).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(services.installer.rollback).mock.invocationCallOrder[0] + ) + expect(pluginService.refresh).toHaveBeenCalledOnce() + + vi.mocked(pluginService.refresh).mockClear() + vi.mocked(services.installer.rollback).mockResolvedValueOnce({ ok: false, error: 'failed' }) + await invoke('plugins:rollbackMarketplacePlugin', { pluginKey: PLUGIN_KEY }) + expect(pluginService.refresh).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc/plugin-marketplaces.ts b/src/main/ipc/plugin-marketplaces.ts new file mode 100644 index 00000000000..8e27ec24911 --- /dev/null +++ b/src/main/ipc/plugin-marketplaces.ts @@ -0,0 +1,76 @@ +import { ipcMain } from 'electron' +import { z } from 'zod' +import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { pluginMarketplaceGitSourceSchema } from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceInstaller } from '../plugins/plugin-marketplace-installer' +import type { PluginMarketplaceService } from '../plugins/plugin-marketplace-service' +import { PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN } from '../plugins/plugin-marketplace-store' +import type { PluginService } from '../plugins/plugin-service' + +export type PluginMarketplaceHandlerServices = { + marketplace: PluginMarketplaceService + installer: PluginMarketplaceInstaller +} + +const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN) +const removeMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema }) +const refreshMarketplaceSchema = z.strictObject({ sourceId: sourceIdSchema.optional() }) +const marketplacePluginSchema = z.strictObject({ + marketplaceSourceId: sourceIdSchema, + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) +const installMarketplacePluginSchema = marketplacePluginSchema.extend({ + marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN), + resolvedCommit: z.string().regex(PLUGIN_COMMIT_PATTERN) +}) +const installedPluginSchema = z.strictObject({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) + +export function registerPluginMarketplaceHandlers( + pluginService: PluginService, + services: PluginMarketplaceHandlerServices +): void { + ipcMain.handle('plugins:listMarketplaces', () => services.marketplace.listSources()) + ipcMain.handle('plugins:addMarketplace', async (_event, args: unknown) => { + const source = pluginMarketplaceGitSourceSchema.parse(args) + return services.marketplace.addSource(source) + }) + ipcMain.handle('plugins:removeMarketplace', async (_event, args: unknown) => { + const { sourceId } = removeMarketplaceSchema.parse(args) + await services.marketplace.removeSource(sourceId) + return services.marketplace.listSources() + }) + ipcMain.handle('plugins:refreshMarketplaces', async (_event, args: unknown) => { + const { sourceId } = refreshMarketplaceSchema.parse(args ?? {}) + return sourceId + ? [await services.marketplace.refreshSource(sourceId)] + : services.marketplace.refreshAll() + }) + ipcMain.handle('plugins:listMarketplacePlugins', () => services.marketplace.listPlugins()) + ipcMain.handle('plugins:previewMarketplacePlugin', async (_event, args: unknown) => { + const parsed = marketplacePluginSchema.parse(args) + return services.installer.preview(parsed.marketplaceSourceId, parsed.pluginKey) + }) + ipcMain.handle('plugins:installMarketplacePlugin', async (_event, args: unknown) => { + const result = await services.installer.install(installMarketplacePluginSchema.parse(args)) + if (result.ok) { + await pluginService.refresh() + } + return result + }) + ipcMain.handle('plugins:previewMarketplaceUpdate', async (_event, args: unknown) => { + const { pluginKey } = installedPluginSchema.parse(args) + return services.installer.previewInstalledUpdate(pluginKey) + }) + ipcMain.handle('plugins:rollbackMarketplacePlugin', async (_event, args: unknown) => { + const { pluginKey } = installedPluginSchema.parse(args) + await pluginService.deactivatePlugin(pluginKey) + const result = await services.installer.rollback(pluginKey) + if (result.ok) { + await pluginService.refresh() + } + return result + }) +} diff --git a/src/main/ipc/plugins.test.ts b/src/main/ipc/plugins.test.ts new file mode 100644 index 00000000000..cdbc8082e6f --- /dev/null +++ b/src/main/ipc/plugins.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile' +import type { PluginService } from '../plugins/plugin-service' +import type { Store } from '../persistence' + +const electronMocks = vi.hoisted(() => ({ handle: vi.fn(), on: vi.fn() })) +vi.mock('electron', () => ({ + ipcMain: { handle: electronMocks.handle, on: electronMocks.on } +})) + +import { + canRemoveInstalledPlugin, + parsePluginConsentArgs, + parsePluginInstallArgs, + registerPluginHandlers +} from './plugins' + +beforeEach(() => { + electronMocks.handle.mockReset() + electronMocks.on.mockReset() +}) + +describe('plugin consent IPC schema', () => { + it('requires the fingerprint reviewed by the caller', () => { + expect(() => + parsePluginConsentArgs({ pluginKey: 'orca-samples.demo', decision: 'approve' }) + ).toThrow() + }) + + it('accepts an explicit reviewed fingerprint', () => { + expect( + parsePluginConsentArgs({ + pluginKey: 'orca-samples.demo', + reviewedFingerprint: 'sha256-reviewed', + decision: 'approve' + }) + ).toEqual({ + pluginKey: 'orca-samples.demo', + reviewedFingerprint: 'sha256-reviewed', + decision: 'approve' + }) + }) +}) + +describe('plugin install IPC schema', () => { + it('requires a non-empty git ref', () => { + expect(() => + parsePluginInstallArgs({ kind: 'git', url: 'https://example.com/plugin.git' }) + ).toThrow() + expect(() => + parsePluginInstallArgs({ kind: 'git', url: 'https://example.com/plugin.git', ref: ' ' }) + ).toThrow() + }) + + it('accepts an explicit git ref', () => { + expect( + parsePluginInstallArgs({ + kind: 'git', + url: 'https://example.com/plugin.git', + ref: ' v1.2.3 ' + }) + ).toEqual({ kind: 'git', url: 'https://example.com/plugin.git', ref: 'v1.2.3' }) + }) + + it('accepts HTTPS and SSH git transports', () => { + expect( + parsePluginInstallArgs({ + kind: 'git', + url: 'ssh://git@example.com/acme/plugin.git', + ref: 'main' + }) + ).toEqual({ + kind: 'git', + url: 'ssh://git@example.com/acme/plugin.git', + ref: 'main' + }) + expect( + parsePluginInstallArgs({ + kind: 'git', + url: 'git@example.com:acme/plugin.git', + ref: 'main' + }) + ).toEqual({ kind: 'git', url: 'git@example.com:acme/plugin.git', ref: 'main' }) + }) + + it('rejects executable helpers and embedded HTTPS credentials', () => { + expect(() => + parsePluginInstallArgs({ kind: 'git', url: 'ext::sh -c calc', ref: 'main' }) + ).toThrow() + expect(() => + parsePluginInstallArgs({ + kind: 'git', + url: 'https://user@example.com/plugin.git', + ref: 'main' + }) + ).toThrow() + }) +}) + +describe('plugin removal authority', () => { + it('allows installed rows but refuses dev overrides and unknown keys', () => { + const service = { + getDiscovered: () => [ + { pluginKey: 'orca-samples.installed', isDev: false }, + { pluginKey: 'orca-samples.dev', isDev: true } + ] + } as unknown as PluginService + + expect(canRemoveInstalledPlugin(service, 'orca-samples.installed')).toBe(true) + expect(canRemoveInstalledPlugin(service, 'orca-samples.dev')).toBe(false) + expect(canRemoveInstalledPlugin(service, 'orca-samples.unknown')).toBe(false) + }) + + it('refuses bundled installs because startup would restore them', () => { + const service = { + getDiscovered: () => [{ pluginKey: 'stablyai.orca-theme', isDev: false }] + } as unknown as PluginService + const lock = { + version: 1, + plugins: { + 'stablyai.orca-theme': { + pluginKey: 'stablyai.orca-theme', + version: '1.0.0', + source: { kind: 'bundled', bundleId: 'stablyai.orca-theme' }, + resolvedCommit: null, + contentHash: 'a'.repeat(64), + consentFingerprint: 'reviewed', + installedAt: 1 + } + } + } satisfies PluginLockfile + + expect(canRemoveInstalledPlugin(service, 'stablyai.orca-theme', lock)).toBe(false) + }) +}) + +describe('plugin settings lifecycle authority', () => { + it('refreshes from the main-process settings listener without renderer follow-up', () => { + let settingsListener!: (updates: { + pluginSystemEnabled?: boolean + devPluginPaths?: string[] + }) => void + const store = { + onSettingsChanged: vi.fn((listener) => { + settingsListener = listener + return vi.fn() + }) + } as unknown as Store + const service = { + setRuntimeDelegate: vi.fn(), + refresh: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginService + registerPluginHandlers(store, service, null) + + settingsListener({ pluginSystemEnabled: false }) + + expect(service.refresh).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/ipc/plugins.ts b/src/main/ipc/plugins.ts new file mode 100644 index 00000000000..29a78812353 --- /dev/null +++ b/src/main/ipc/plugins.ts @@ -0,0 +1,257 @@ +import { ipcMain } from 'electron' +import { z } from 'zod' +import type { Store } from '../persistence' +import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import { getUserPluginsDir, getPluginsDataDir } from '../plugins/plugin-discovery' +import { + installPluginFromGit, + installPluginFromLocalPath, + readPluginLockfile, + removeInstalledPlugin +} from '../plugins/plugin-install' +import { applyPluginConsent, applyPluginEnablement } from '../plugins/plugin-enablement' +import { buildPluginList, type PluginListEntry } from '../plugins/plugin-list-projection' +import type { PluginService } from '../plugins/plugin-service' +import { bindPluginPanelOwnerLifecycle } from '../plugins/plugin-panel-owner-lifecycle' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { pluginConsentRequestSchema } from '../../shared/plugins/plugin-consent-request' +import { normalizePluginIdList } from '../../shared/plugins/plugin-consent-state' +import { + isAllowedPluginGitUrl, + type PluginLockfile +} from '../../shared/plugins/plugin-install-lockfile' +import { + registerPluginMarketplaceHandlers, + type PluginMarketplaceHandlerServices +} from './plugin-marketplaces' + +export function parsePluginConsentArgs(args: unknown): z.infer { + return pluginConsentRequestSchema.parse(args) +} + +const setEnabledArgsSchema = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + enabled: z.boolean() +}) + +const readPanelEntryArgsSchema = z.object({ + pluginKey: z.string().min(1), + panelId: z.string().min(1) +}) + +const invokeCommandArgsSchema = z.object({ + pluginKey: z.string().min(1), + commandId: z.string().min(1), + args: z.unknown().optional() +}) + +const installArgsSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('local-path'), path: z.string().min(1) }), + z.object({ + kind: z.literal('git'), + url: z.string().trim().min(1).refine(isAllowedPluginGitUrl, 'git URL must use HTTPS or SSH'), + // Why: installs must stay reproducible even when callers bypass renderer validation. + ref: z.string().trim().min(1) + }) +]) + +export function parsePluginInstallArgs(args: unknown): z.infer { + return installArgsSchema.parse(args) +} + +const removeArgsSchema = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key') +}) +const logsArgsSchema = z.object({ pluginKey: z.string().min(1) }) + +export async function listPluginsForClients( + pluginService: PluginService +): Promise { + await pluginService.whenReady() + const lock = await readPluginLockfile(getUserPluginsDir(pluginService.options.userDataPath)) + return buildPluginList(pluginService, lock) +} + +export function canRemoveInstalledPlugin( + pluginService: PluginService, + pluginKey: string, + lock?: PluginLockfile +): boolean { + return ( + lock?.plugins[pluginKey]?.source.kind !== 'bundled' && + pluginService.getDiscovered().some((plugin) => plugin.pluginKey === pluginKey && !plugin.isDev) + ) +} + +function rendererPanelOwner(webContentsId: number): string { + return `renderer:${webContentsId}` +} + +export function registerPluginHandlers( + store: Store, + pluginService: PluginService, + runtime: OrcaRuntimeService | null, + marketplaceServices?: PluginMarketplaceHandlerServices +): void { + // The runtime IS the delegate: the structural PluginRuntimeDelegate type + // keeps the facade electron-free while main binds the real service. + if (runtime) { + pluginService.setRuntimeDelegate(runtime) + } + + store.onSettingsChanged((updates) => { + if ('pluginSystemEnabled' in updates || 'devPluginPaths' in updates) { + // Main owns plugin lifecycle. Renderer follow-up refreshes are UX only; + // a crashed or remote caller must not leave old workers authoritative. + void pluginService.refresh().catch((error) => { + console.warn('[plugins] failed to apply plugin settings change:', error) + }) + } + }) + + // Why: startup discovery is fire-and-forget; every handler awaits it so an + // early renderer fetch can't observe the empty pre-discovery list. + ipcMain.handle('plugins:list', async () => listPluginsForClients(pluginService)) + ipcMain.handle('plugins:listLanguagePacks', async () => { + await pluginService.whenReady() + return pluginService.contentPacks.languagePacks.list() + }) + ipcMain.handle('plugins:consent', async (event, args: unknown) => { + await pluginService.whenReady() + const parsed = parsePluginConsentArgs(args) + await applyPluginConsent({ + store, + pluginService, + pluginKey: parsed.pluginKey, + reviewedFingerprint: parsed.reviewedFingerprint, + decision: parsed.decision, + originWebContentsId: event.sender.id + }) + return listPluginsForClients(pluginService) + }) + + ipcMain.handle('plugins:setEnabled', async (event, args: unknown) => { + await pluginService.whenReady() + const parsed = setEnabledArgsSchema.parse(args) + await applyPluginEnablement({ + store, + pluginService, + pluginKey: parsed.pluginKey, + enabled: parsed.enabled, + originWebContentsId: event.sender.id + }) + return listPluginsForClients(pluginService) + }) + + // Why: the renderer renders panel HTML via a sandboxed iframe srcdoc, so it + // needs (CSP-wrapped) file contents — never a file:// path — across IPC. + ipcMain.handle( + 'plugins:readPanelEntry', + async (event, args: unknown): Promise => { + const ownerKey = rendererPanelOwner(event.sender.id) + const ownerLease = bindPluginPanelOwnerLifecycle(event.sender, () => + pluginService.panels.revokeOwner(ownerKey) + ) + await pluginService.whenReady() + const parsed = readPanelEntryArgsSchema.parse(args) + const entry = await pluginService.panels.open(ownerKey, parsed.pluginKey, parsed.panelId) + if (!ownerLease.isCurrent()) { + pluginService.panels.revokeOwner(ownerKey) + return null + } + return entry + } + ) + + // Panel-originated actions relayed by the renderer's postMessage bridge + // host. Capability enforcement happens in main, never in the renderer. + ipcMain.handle( + 'plugins:panelAction', + async (event, args: unknown): Promise => { + await pluginService.whenReady() + return pluginService.panels.execute(rendererPanelOwner(event.sender.id), args) + } + ) + + ipcMain.handle('plugins:invokeCommand', async (_event, args: unknown) => { + await pluginService.whenReady() + const parsed = invokeCommandArgsSchema.parse(args) + return pluginService.invokeCommand(parsed.pluginKey, parsed.commandId, parsed.args) + }) + + ipcMain.handle('plugins:install', async (_event, args: unknown) => { + await pluginService.whenReady() + const parsed = parsePluginInstallArgs(args) + const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath) + const hostVersion = pluginService.options.hostVersion + const blockedPluginReason = (pluginKey: string): string | null => + pluginService.options.getPluginKillListEntry?.(pluginKey)?.reason ?? null + const result = + parsed.kind === 'local-path' + ? await installPluginFromLocalPath({ + pluginsDir, + sourcePath: parsed.path, + hostVersion, + blockedPluginReason + }) + : await installPluginFromGit({ + pluginsDir, + url: parsed.url, + ref: parsed.ref, + hostVersion, + blockedPluginReason + }) + if (result.ok) { + await pluginService.refresh() + } + return result + }) + + ipcMain.handle('plugins:remove', async (event, args: unknown) => { + await pluginService.whenReady() + const parsed = removeArgsSchema.parse(args) + const pluginsDir = getUserPluginsDir(pluginService.options.userDataPath) + const lock = await readPluginLockfile(pluginsDir) + if (!canRemoveInstalledPlugin(pluginService, parsed.pluginKey, lock)) { + throw new Error(`cannot remove protected or non-installed plugin ${parsed.pluginKey}`) + } + await pluginService.deactivatePlugin(parsed.pluginKey) + await removeInstalledPlugin({ + pluginsDir, + pluginsDataDir: getPluginsDataDir(pluginService.options.userDataPath), + pluginKey: parsed.pluginKey + }) + // Drop the stale consent so a later reinstall re-prompts from scratch. + const settings = store.getSettings() + const consents = { ...settings.pluginConsents } + delete consents[parsed.pluginKey] + const disabledPlugins = normalizePluginIdList(settings.disabledPlugins).filter( + (pluginKey) => pluginKey !== parsed.pluginKey + ) + store.updateSettings( + { pluginConsents: consents, disabledPlugins }, + { notifyListeners: true, originWebContentsId: event.sender.id } + ) + await pluginService.refresh() + return listPluginsForClients(pluginService) + }) + + ipcMain.handle('plugins:getLogs', async (_event, args: unknown) => { + const parsed = logsArgsSchema.parse(args) + return pluginService.getLogs(parsed.pluginKey) + }) + + // Re-discover after settings edits (feature flag, dev paths) — the + // renderer calls this right after updating those settings. + ipcMain.handle('plugins:refresh', async () => { + await pluginService.refresh() + return listPluginsForClients(pluginService) + }) + if (marketplaceServices) { + registerPluginMarketplaceHandlers(pluginService, marketplaceServices) + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index f10355161e7..763085c0699 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -11,6 +11,7 @@ import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' import type { TuiAgent } from '../../shared/types' import type { AgentSessionOwnerBinding } from '../../shared/agent-session-host-authority' +import { AGENT_SESSION_CLAIM_DIGEST_VERSION } from '../../shared/agent-session-host-authority' import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error' const isWindowsHost = process.platform === 'win32' @@ -1682,6 +1683,28 @@ describe('registerPtyHandlers', () => { expect(env.LANG).toBe('fr_FR.UTF-8') }) + it('strips inherited Claude child-session stamps from a local spawn env', async () => { + // Why: the local provider spreads main's process.env, so a GUI launched from + // inside a Claude session would stamp every pane as a nested child and Claude + // would silently disable transcript persistence. Not gated on isDaemonHostSpawn. + const env = await spawnAndGetEnv(undefined, { + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'session_01UCkWN5nDXNyD1V7cfamCxa' + }) + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + expect(env.CLAUDE_CODE_SESSION_ID).toBeUndefined() + expect(env.CLAUDE_CODE_BRIDGE_SESSION_ID).toBeUndefined() + }) + + it('keeps an explicitly requested Claude child-session stamp on a local spawn', async () => { + const env = await spawnAndGetEnv( + { CLAUDE_CODE_CHILD_SESSION: '1' }, + { CLAUDE_CODE_CHILD_SESSION: '1' } + ) + expect(env.CLAUDE_CODE_CHILD_SESSION).toBe('1') + }) + it('always sets TERM and COLORTERM regardless of env', async () => { const env = await spawnAndGetEnv() expect(env.TERM).toBe('xterm-256color') @@ -2901,6 +2924,39 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.envToDelete ?? []).not.toEqual(expect.arrayContaining(['CODEX_HOME'])) }) + it('strips inherited Claude child-session stamps from daemon spawns', async () => { + // Why: a daemon forked from inside a Claude Code session inherits these + // stamps and would mark every terminal as a nested Claude child, which + // silently disables transcript persistence for real user sessions. + const spawnOptions = await daemonSpawnAndGetOptions(undefined, undefined, undefined, { + CLAUDE_CODE_CHILD_SESSION: '1', + CLAUDE_CODE_SESSION_ID: '85935aed-98a7-4094-89a8-85c75e1a5a95', + CLAUDE_CODE_BRIDGE_SESSION_ID: 'session_01UCkWN5nDXNyD1V7cfamCxa' + }) + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ]) + ) + }) + + it('preserves an explicitly requested Claude child-session stamp', async () => { + // Why: only inherited values are poison; a caller deliberately spawning a + // nested Claude child passes the stamp in args.env and must keep it. + const spawnOptions = await daemonSpawnAndGetOptions( + { CLAUDE_CODE_CHILD_SESSION: '1' }, + undefined, + undefined, + { CLAUDE_CODE_CHILD_SESSION: '1' } + ) + expect(spawnOptions.envToDelete ?? []).not.toEqual( + expect.arrayContaining(['CLAUDE_CODE_CHILD_SESSION']) + ) + expect(spawnOptions.env.CLAUDE_CODE_CHILD_SESSION).toBe('1') + }) + it('prepends the bare-orca CLI shim dir to PATH for packaged Linux spawns', async () => { const originalPlatform = process.platform Object.defineProperty(process, 'platform', { @@ -2982,6 +3038,84 @@ describe('registerPtyHandlers', () => { expect(spawnOptions.env.ORCA_AGENT_HOOK_TOKEN).toBe('agent-token') }) + it('strips inherited Claude child-session stamps from runtime-created PTYs', async () => { + // Why: the runtime controller is the `orca` CLI / automation spawn path and + // assembles envToDelete separately from the renderer's pty:spawn handler; + // without its own case the two paths can silently drift apart. + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const daemonSpawn = setupDaemonAdapter() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn() + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, worktreeId: 'wt-runtime', env: {} }) + + const spawnOptions = daemonSpawn.mock.calls.at(-1)?.[0] as DaemonSpawnCall + expect(spawnOptions.envToDelete).toEqual( + expect.arrayContaining([ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ]) + ) + }) + + it('strips inherited Claude child-session stamps from a local runtime-created PTY', async () => { + // Why: the runtime strip is deliberately not gated on isDaemonHostSpawn, so + // the local provider — which spreads main's own process.env — needs its own + // case; a daemon-only test would still pass if someone added that gate. + type RuntimeSpawnController = { + spawn(args: { + cols: number + rows: number + worktreeId?: string + env?: Record + }): Promise<{ id: string }> + } + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => 'handle-runtime-local') + } + const saved = process.env.CLAUDE_CODE_CHILD_SESSION + process.env.CLAUDE_CODE_CHILD_SESSION = '1' + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const controller = runtime.setPtyController.mock.calls[0]?.[0] as RuntimeSpawnController + + await controller.spawn({ cols: 80, rows: 24, env: {} }) + + const env = spawnMock.mock.calls.at(-1)![2].env as Record + expect(env.CLAUDE_CODE_CHILD_SESSION).toBeUndefined() + } finally { + if (saved === undefined) { + delete process.env.CLAUDE_CODE_CHILD_SESSION + } else { + process.env.CLAUDE_CODE_CHILD_SESSION = saved + } + } + }) + it('threads the validated pane identity into registerPty for a runtime-created daemon PTY (#7587)', async () => { type RuntimeSpawnController = { spawn(args: { @@ -5692,6 +5826,101 @@ describe('registerPtyHandlers', () => { }) }) + it('reports agent ownership through pty:listSessions so the renderer cannot guess it', async () => { + registerPtyHandlers(mainWindow as never) + // Why: the renderer's binding map is empty during restore, so agent ownership is the only + // positive liveness evidence it has. Dropping it here force-killed live sessions (#8459). + const owner = { + claim: { + digestVersion: AGENT_SESSION_CLAIM_DIGEST_VERSION, + keyId: 'key-1', + identityDigest: 'a'.repeat(43), + worktreeScopeDigest: 'b'.repeat(43), + agent: 'codex' + }, + generation: 'gen-1', + phase: 'live', + ptyId: 'agent-pty', + surface: { + worktreeId: 'repo::/workspace', + tabId: 'tab', + leafId: '11111111-1111-4111-8111-111111111111', + terminalHandle: 'term_claimed' + } + } + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => [ + { id: 'agent-pty', cwd: '/workspace', title: 'codex', agentSessionOwners: [owner] }, + { id: 'plain-pty', cwd: '/tmp', title: 'zsh' } + ]), + // Why: this provider serializes claims, so its silence about an owner is authoritative. + providesAgentSessionOwnerListings: () => true, + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + + const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as { + id: string + agentOwnership: string + }[] + + expect(sessions.find((s) => s.id === 'agent-pty')?.agentOwnership).toBe('present') + expect(sessions.find((s) => s.id === 'plain-pty')?.agentOwnership).toBe('absent') + }) + + it('reports unknown ownership when the provider cannot serialize claims', async () => { + registerPtyHandlers(mainWindow as never) + // Why: a legacy daemon generation or older SSH relay lists no owners for a session that may + // have one. Reporting that silence as 'absent' is what let live agent sessions be killed (#8459). + setLocalPtyProvider({ + spawn: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent: vi.fn(), + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => [{ id: 'legacy-pty', cwd: '/workspace', title: 'zsh' }]), + providesAgentSessionOwnerListings: () => false, + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + + const sessions = (await handlers.get('pty:listSessions')!(null, undefined)) as { + id: string + agentOwnership: string + }[] + + expect(sessions.find((s) => s.id === 'legacy-pty')?.agentOwnership).toBe('unknown') + }) + it('kills app-scoped SSH PTY ids through the parsed provider when ownership is not rebuilt', async () => { const localShutdown = vi.fn() setLocalPtyProvider({ diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ff089432508..b1a5927247d 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -175,6 +175,7 @@ import { import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { resolveLocalProjectRuntimeForWorktreeId } from '../local-project-runtime-resolution' import { isPtyIncarnationId } from '../../shared/pty-incarnation' +import type { PtyListedSession } from '../../shared/pty-listed-session' // ─── Provider Registry ────────────────────────────────────────────── // Routes PTY operations by connectionId (null = local provider). @@ -237,6 +238,13 @@ const AGENT_HOOK_RUNTIME_ENV_KEYS = [ 'ORCA_CLAUDE_AGENT_STATUS_SETTINGS' ] as const +// Why: Orca never sets these, so an inherited value means a pty host launched from inside a Claude session — Claude reads it as a nested child and silently stops persisting the transcript. +const CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS = [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' +] as const + export function getPtyIdForPaneKey(paneKey: string): string | undefined { return paneKeyPtyId.get(paneKey) } @@ -901,14 +909,15 @@ function exposePiManagedExtensionEnv( } } +// Why: variadic because a nested call per source made intermediate `string[] | undefined` collide with the parameter type. function mergePtyEnvDeletions( existingKeys: string[] | undefined, - additionalKeys: readonly string[] + ...additionalKeyGroups: readonly (readonly string[])[] ): string[] | undefined { - if (!existingKeys && additionalKeys.length === 0) { + if (!existingKeys && additionalKeyGroups.every((keys) => keys.length === 0)) { return undefined } - return Array.from(new Set([...(existingKeys ?? []), ...additionalKeys])) + return Array.from(new Set([...(existingKeys ?? []), ...additionalKeyGroups.flat()])) } function removeCodexHomeDeletionRequests(keys: string[] | undefined): string[] | undefined { @@ -925,6 +934,15 @@ function getInheritedAgentHookEnvKeysToDelete( return AGENT_HOOK_RUNTIME_ENV_KEYS.filter((key) => env[key] === undefined) } +function getInheritedClaudeSessionStampEnvKeysToDelete( + spawnEnv: Record | undefined +): string[] { + const env = spawnEnv ?? {} + // Why: strip only values inherited from the pty host; a caller that explicitly + // provides a stamp (deliberately spawning a nested Claude child) keeps it. + return CLAUDE_CHILD_SESSION_STAMP_ENV_KEYS.filter((key) => env[key] === undefined) +} + // Why: a nested terminal can inherit prior OpenCode/Pi/OMP overlay env; restore the user's recorded source dir, else strip only Orca-owned values. function restoreOrStripOverlayEnv( baseEnv: Record, @@ -3327,8 +3345,11 @@ export function registerPtyHandlers( args.onPtySpawnCommitted?.() } spawnOptions.envToDelete = mergePtyEnvDeletions( - mergePtyEnvDeletions(authEnvToDelete, args.envToDelete ?? []), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [] + authEnvToDelete, + args.envToDelete ?? [], + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(env) : [], + // Why: ungated, unlike the agent-hook keys — the local provider and the relay host also spread their own process.env into every spawn. + getInheritedClaudeSessionStampEnvKeysToDelete(env) ) if (skipCodexHomeEnv) { spawnOptions.envToDelete = mergePtyEnvDeletions( @@ -4472,16 +4493,12 @@ export function registerPtyHandlers( ? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS'] : undefined let combinedEnvToDelete = mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions( - mergePtyEnvDeletions(envToDelete, args.envToDelete ?? []), - agentTeamsEnvToDelete ?? [] - ), - isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [] - ), - skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [] - ), + envToDelete, + args.envToDelete ?? [], + agentTeamsEnvToDelete ?? [], + isDaemonHostSpawn ? getInheritedAgentHookEnvKeysToDelete(spawnEnv) : [], + getInheritedClaudeSessionStampEnvKeysToDelete(spawnEnv), + skipCodexHomeEnv ? CODEX_HOME_ENV_KEYS : [], // Why: the persistent daemon compares its own merged CODEX_HOME pair; // main cannot safely decide ownership for a process it may not parent. stripInheritedOrcaCodexHome ? ['ORCA_CODEX_HOME'] : [] @@ -5506,33 +5523,37 @@ export function registerPtyHandlers( } }) - ipcMain.handle( - 'pty:listSessions', - async (): Promise<{ id: string; cwd: string; title: string }[]> => { - const deduped = new Map() - const admission = new PtyProcessListAdmission() - await visitPtyProcessListingsInBatches( - registeredPtyProviders(), - ({ provider, connectionId }) => - connectionId === null - ? provider.listProcesses() - : provider.listProcesses().catch(() => []), - ({ connectionId }, sessions) => { - for (const rawSession of sessions) { - const session = admission.admit(rawSession) - // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. - ptyOwnership.set(session.id, connectionId) - deduped.set(session.id, { - id: session.id, - cwd: session.cwd, - title: session.title - }) - } + ipcMain.handle('pty:listSessions', async (): Promise => { + const deduped = new Map() + const admission = new PtyProcessListAdmission() + await visitPtyProcessListingsInBatches( + registeredPtyProviders(), + ({ provider, connectionId }) => + connectionId === null ? provider.listProcesses() : provider.listProcesses().catch(() => []), + ({ provider, connectionId }, sessions) => { + for (const rawSession of sessions) { + const session = admission.admit(rawSession) + // Why: kill actions only send back the PTY id, so rebuild ownership while listing to keep reconnect-discovered remote sessions routed to their provider. + ptyOwnership.set(session.id, connectionId) + deduped.set(session.id, { + id: session.id, + cwd: session.cwd, + title: session.title, + // Why: the renderer's binding map is empty during restore, so ownership is the only + // liveness evidence it has. Absence is authoritative only from a provider that + // serializes claims — otherwise it is 'unknown', never 'absent' (#8459). + agentOwnership: + (session.agentSessionOwners?.length ?? 0) > 0 + ? 'present' + : provider.providesAgentSessionOwnerListings?.(session.id) === true + ? 'absent' + : 'unknown' + }) } - ) - return Array.from(deduped.values()) - } - ) + } + ) + return Array.from(deduped.values()) + }) ipcMain.on( 'pty:getAuthoritativeBufferSnapshotCapabilitiesSync', diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index d2b36bdf08c..51a417f7ad7 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -532,7 +532,7 @@ describe('registerCoreHandlers', () => { expect(registerFilesystemHandlersMock).toHaveBeenCalledWith(store) expect(registerRuntimeHandlersMock).toHaveBeenCalledWith(runtime) expect(registerRuntimeEnvironmentHandlersMock).toHaveBeenCalledWith(store) - expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store) + expect(registerEphemeralVmHandlersMock).toHaveBeenCalledWith(store, undefined) expect(registerAiVaultHandlersMock).toHaveBeenCalledWith( expect.objectContaining({ getAdditionalCodexHomePaths: getAdditionalAiVaultCodexHomePaths, diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 2c834255eab..25ddf067727 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -48,6 +48,7 @@ import { registerTelemetryHandlers } from './telemetry' import { registerBrowserHandlers } from './browser' import { registerShellHandlers } from './shell' import { registerPetHandlers } from './pet' +import { registerPluginHandlers } from './plugins' import { registerUIHandlers, setTrustedUIRendererWebContentsId } from './ui' import { registerEmulatorFrameStreamHandlers } from './emulator-frame-stream' import { registerEmulatorVideoStreamHandlers } from './emulator-video-stream' @@ -86,6 +87,8 @@ import { prepareRuntimeAiVaultSessionResume, scanRuntimeAiVaultSessions } from '../ai-vault/runtime-session-scanner' +import type { PluginService } from '../plugins/plugin-service' +import type { PluginMarketplaceHandlerServices } from './plugin-marketplaces' let registered = false @@ -115,7 +118,9 @@ export function registerCoreHandlers( agentAwakeService?: AgentAwakeService, crashReports?: CrashReportStore, keybindings?: KeybindingService, - lifecycleOptions: CoreHandlerLifecycleOptions = {} + lifecycleOptions: CoreHandlerLifecycleOptions = {}, + pluginService?: PluginService, + marketplaceServices?: PluginMarketplaceHandlerServices ): void { // Why: on macOS the app can stay alive after all windows close, then // openMainWindow() is called again on 'activate'. ipcMain.handle() throws @@ -175,7 +180,12 @@ export function registerCoreHandlers( registerAutomationHandlers(store, automations) } if (keybindings) { - registerKeybindingHandlers(keybindings) + registerKeybindingHandlers(keybindings, () => { + void pluginService?.reconcileActivationState() + }) + } + if (pluginService) { + registerPluginHandlers(store, pluginService, runtime, marketplaceServices) } registerTelemetryHandlers(store) registerOrcaProfileHandlers(store, { @@ -201,7 +211,7 @@ export function registerCoreHandlers( registerFilesystemWatcherHandlers() registerRuntimeHandlers(runtime) registerRuntimeEnvironmentHandlers(store) - registerEphemeralVmHandlers(store) + registerEphemeralVmHandlers(store, pluginService) registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, diff --git a/src/main/ipc/runtime-environment-recovery-handler.ts b/src/main/ipc/runtime-environment-recovery-handler.ts new file mode 100644 index 00000000000..7477956f9ed --- /dev/null +++ b/src/main/ipc/runtime-environment-recovery-handler.ts @@ -0,0 +1,11 @@ +import { ipcMain } from 'electron' +import { retryRemoteRuntimeSharedControlConnectionsNow } from './runtime-environment-request-connections' + +const RETRY_CONNECTIONS_NOW_CHANNEL = 'runtimeEnvironments:retryConnectionsNow' + +export function registerRuntimeEnvironmentRecoveryHandler(): void { + ipcMain.removeHandler(RETRY_CONNECTIONS_NOW_CHANNEL) + ipcMain.handle(RETRY_CONNECTIONS_NOW_CHANNEL, () => { + retryRemoteRuntimeSharedControlConnectionsNow() + }) +} diff --git a/src/main/ipc/runtime-environment-request-connections.ts b/src/main/ipc/runtime-environment-request-connections.ts index 3bfcf0649af..361264d917e 100644 --- a/src/main/ipc/runtime-environment-request-connections.ts +++ b/src/main/ipc/runtime-environment-request-connections.ts @@ -103,6 +103,12 @@ export function reconnectRemoteRuntimeSharedControlConnection(environmentId: str sharedControlConnections.get(environmentId)?.connection.reconnectNow() } +export function retryRemoteRuntimeSharedControlConnectionsNow(): void { + for (const { connection } of sharedControlConnections.values()) { + connection.retryNow() + } +} + function getSharedControlConnection( environmentId: string, pairing: PairingOffer diff --git a/src/main/ipc/runtime-environment-shared-control-support.ts b/src/main/ipc/runtime-environment-shared-control-support.ts new file mode 100644 index 00000000000..4603ff69d70 --- /dev/null +++ b/src/main/ipc/runtime-environment-shared-control-support.ts @@ -0,0 +1,80 @@ +import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' +import { sendRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { markEnvironmentUsed } from '../../shared/runtime-environment-store' +import type { + getPreferredPairingOffer, + KnownRuntimeEnvironment +} from '../../shared/runtime-environments' +import type { RuntimeStatus } from '../../shared/runtime-types' + +const sharedControlSupport = new Map }>() + +export function resetSharedControlSupport(): void { + sharedControlSupport.clear() +} + +export function clearSharedControlSupport(environmentId: string): void { + sharedControlSupport.delete(environmentId) +} + +export async function supportsSharedControl( + userDataPath: string, + environment: KnownRuntimeEnvironment, + pairing: ReturnType, + timeoutMs: number +): Promise { + const cacheKey = getSharedControlSupportCacheKey(environment, pairing) + const cached = sharedControlSupport.get(environment.id) + if (cached?.cacheKey === cacheKey) { + return cached.check + } + let resolvedCacheKey = cacheKey + const check = (async () => { + const response = await sendRemoteRuntimeRequest( + pairing, + 'status.get', + undefined, + timeoutMs + ) + if (response.ok === true) { + markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) + resolvedCacheKey = getSharedControlSupportCacheKey( + environment, + pairing, + response._meta.runtimeId + ) + return ( + response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) === true + ) + } + return false + })() + // Why: support belongs to the saved pairing/runtime identity, not its mutable display name. + sharedControlSupport.set(environment.id, { cacheKey, check }) + try { + const supported = await check + const cachedAfterCheck = sharedControlSupport.get(environment.id) + if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { + sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) + } + return supported + } catch (error) { + if (sharedControlSupport.get(environment.id)?.check === check) { + sharedControlSupport.delete(environment.id) + } + throw error + } +} + +function getSharedControlSupportCacheKey( + environment: KnownRuntimeEnvironment, + pairing: ReturnType, + runtimeId = environment.runtimeId +): string { + return [ + runtimeId ?? 'unknown-runtime', + pairing.endpoint, + pairing.deviceToken, + pairing.publicKeyB64 + ].join('\0') +} diff --git a/src/main/ipc/runtime-environment-transport-routing.ts b/src/main/ipc/runtime-environment-transport-routing.ts index 46241a82521..852b97273c5 100644 --- a/src/main/ipc/runtime-environment-transport-routing.ts +++ b/src/main/ipc/runtime-environment-transport-routing.ts @@ -1,11 +1,10 @@ -import { - getPreferredPairingOffer, - type KnownRuntimeEnvironment -} from '../../shared/runtime-environments' +import { getPreferredPairingOffer } from '../../shared/runtime-environments' import { resolveEnvironment, markEnvironmentUsed } from '../../shared/runtime-environment-store' -import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' +import type { + RuntimeOrchestrationEnvelope, + RuntimeRpcResponse +} from '../../shared/runtime-rpc-envelope' import type { RuntimeStatus } from '../../shared/runtime-types' -import { REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY } from '../../shared/protocol-version' import { sendRemoteRuntimeRequest, subscribeRemoteRuntimeRequest, @@ -22,14 +21,15 @@ import { import { attachRemoteControlDiagnostics } from './runtime-environment-status-diagnostics' import { runtimeEnvironmentRevisionFailure } from './runtime-environment-revision-guard' import { withTailscaleHintForResponse } from './runtime-environment-tailscale-response' +import { + clearSharedControlSupport, + resetSharedControlSupport, + supportsSharedControl +} from './runtime-environment-shared-control-support' const DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS = 15_000 -const sharedControlSupport = new Map }>() -export const resetSharedControlSupport = (): void => sharedControlSupport.clear() - -export const clearSharedControlSupport = (environmentId: string): void => - void sharedControlSupport.delete(environmentId) +export { clearSharedControlSupport, resetSharedControlSupport } export async function getRuntimeEnvironmentStatus( userDataPath: string, @@ -81,7 +81,8 @@ export async function callRuntimeEnvironment( method: string, params: unknown, timeoutMs?: number, - expectedEnvironmentPairingRevision?: number + expectedEnvironmentPairingRevision?: number, + envelope?: RuntimeOrchestrationEnvelope ): Promise> { const environment = resolveEnvironment(userDataPath, selector) // Why: connection failures reject (they don't resolve as ok:false), so the @@ -104,6 +105,17 @@ export async function callRuntimeEnvironment( const pairing = getPreferredPairingOffer(currentEnvironment) endpoint = pairing.endpoint const effectiveTimeoutMs = timeoutMs ?? DEFAULT_REMOTE_RUNTIME_TIMEOUT_MS + if (envelope) { + const response = await sendRemoteRuntimeRequest( + pairing, + method, + params, + effectiveTimeoutMs, + envelope + ) + markEnvironmentUsedFromResponse(userDataPath, currentEnvironment.id, response) + return response + } if (shouldUseCachedRequestConnection(method)) { const response = await sendRemoteRuntimeConnectionRequest( currentEnvironment.id, @@ -257,66 +269,3 @@ function shouldUseSharedControlSubscription(method: string): boolean { method === 'files.watch' ) } - -async function supportsSharedControl( - userDataPath: string, - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - timeoutMs: number -): Promise { - const cacheKey = getSharedControlSupportCacheKey(environment, pairing) - const cached = sharedControlSupport.get(environment.id) - if (cached?.cacheKey === cacheKey) { - return cached.check - } - let resolvedCacheKey = cacheKey - const check = (async () => { - const response = await sendRemoteRuntimeRequest( - pairing, - 'status.get', - undefined, - timeoutMs - ) - if (response.ok === true) { - markEnvironmentUsed(userDataPath, environment.id, { runtimeId: response._meta.runtimeId }) - resolvedCacheKey = getSharedControlSupportCacheKey( - environment, - pairing, - response._meta.runtimeId - ) - return ( - response.result.capabilities?.includes(REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY) === true - ) - } - return false - })() - // Why: the same saved host can be re-paired or point at a different runtime - // binary over time; capability support belongs to that pairing/runtime identity. - sharedControlSupport.set(environment.id, { cacheKey, check }) - try { - const supported = await check - const cachedAfterCheck = sharedControlSupport.get(environment.id) - if (cachedAfterCheck?.check === check && cachedAfterCheck.cacheKey !== resolvedCacheKey) { - sharedControlSupport.set(environment.id, { cacheKey: resolvedCacheKey, check }) - } - return supported - } catch (error) { - if (sharedControlSupport.get(environment.id)?.check === check) { - sharedControlSupport.delete(environment.id) - } - throw error - } -} - -function getSharedControlSupportCacheKey( - environment: KnownRuntimeEnvironment, - pairing: ReturnType, - runtimeId = environment.runtimeId -): string { - return [ - runtimeId ?? 'unknown-runtime', - pairing.endpoint, - pairing.deviceToken, - pairing.publicKeyB64 - ].join('\0') -} diff --git a/src/main/ipc/runtime-environments.test.ts b/src/main/ipc/runtime-environments.test.ts index 5a0cd612864..a261625a6f0 100644 --- a/src/main/ipc/runtime-environments.test.ts +++ b/src/main/ipc/runtime-environments.test.ts @@ -21,6 +21,7 @@ const { subscribeRemoteRuntimeSharedControlRequestMock, getRemoteRuntimeSharedControlDiagnosticsMock, reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNowMock, closeRemoteRuntimeRequestConnectionMock } = vi.hoisted(() => ({ handleMock: vi.fn(), @@ -35,6 +36,7 @@ const { subscribeRemoteRuntimeSharedControlRequestMock: vi.fn(), getRemoteRuntimeSharedControlDiagnosticsMock: vi.fn(), reconnectRemoteRuntimeSharedControlConnectionMock: vi.fn(), + retryRemoteRuntimeSharedControlConnectionsNowMock: vi.fn(), closeRemoteRuntimeRequestConnectionMock: vi.fn() })) @@ -59,6 +61,7 @@ vi.mock('./runtime-environment-request-connections', () => ({ subscribeRemoteRuntimeSharedControlRequest: subscribeRemoteRuntimeSharedControlRequestMock, getRemoteRuntimeSharedControlDiagnostics: getRemoteRuntimeSharedControlDiagnosticsMock, reconnectRemoteRuntimeSharedControlConnection: reconnectRemoteRuntimeSharedControlConnectionMock, + retryRemoteRuntimeSharedControlConnectionsNow: retryRemoteRuntimeSharedControlConnectionsNowMock, closeRemoteRuntimeRequestConnection: closeRemoteRuntimeRequestConnectionMock })) @@ -115,6 +118,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { getRemoteRuntimeSharedControlDiagnosticsMock.mockReset() getRemoteRuntimeSharedControlDiagnosticsMock.mockReturnValue(null) reconnectRemoteRuntimeSharedControlConnectionMock.mockReset() + retryRemoteRuntimeSharedControlConnectionsNowMock.mockReset() closeRemoteRuntimeRequestConnectionMock.mockReset() }) @@ -131,6 +135,7 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:resolve', 'runtimeEnvironments:remove', 'runtimeEnvironments:disconnect', + 'runtimeEnvironments:retryConnectionsNow', 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', @@ -153,11 +158,21 @@ describe('registerRuntimeEnvironmentHandlers', () => { 'runtimeEnvironments:getStatus', 'runtimeEnvironments:call', 'runtimeEnvironments:subscribe', - 'runtimeEnvironments:unsubscribe' + 'runtimeEnvironments:unsubscribe', + 'runtimeEnvironments:retryConnectionsNow' ]) expect(removeAllListenersMock).toHaveBeenCalledWith('runtimeEnvironments:subscriptionBinary') }) + it('advances pending shared-control reconnects through IPC', async () => { + registerRuntimeEnvironmentHandlers(store as never) + + const retryConnectionsNow = handler('runtimeEnvironments:retryConnectionsNow') + await retryConnectionsNow(null, undefined) + + expect(retryRemoteRuntimeSharedControlConnectionsNowMock).toHaveBeenCalledTimes(1) + }) + it('stores, resolves, lists, and removes environments under Electron userData', async () => { registerRuntimeEnvironmentHandlers(store as never) diff --git a/src/main/ipc/runtime-environments.ts b/src/main/ipc/runtime-environments.ts index 9662b8ab598..2bd0c1e181c 100644 --- a/src/main/ipc/runtime-environments.ts +++ b/src/main/ipc/runtime-environments.ts @@ -15,6 +15,7 @@ import type { RuntimeRpcResponse } from '../../shared/runtime-rpc-envelope' import type { RemoteRuntimeSubscription } from '../../shared/remote-runtime-client' import type { Store } from '../persistence' import { closeRemoteRuntimeRequestConnection } from './runtime-environment-request-connections' +import { registerRuntimeEnvironmentRecoveryHandler } from './runtime-environment-recovery-handler' import { advanceRuntimeEnvironmentTransportGeneration, getRuntimeEnvironmentTransportGeneration @@ -122,6 +123,7 @@ export function registerRuntimeEnvironmentHandlers(store: Store): void { return { disconnected: redactRuntimeEnvironment(environment) } } ) + registerRuntimeEnvironmentRecoveryHandler() ipcMain.handle( 'runtimeEnvironments:getStatus', async ( diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 01bbcbd12b4..71ca362f08e 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -384,6 +384,27 @@ describe('registerSettingsHandlers', () => { ) }) + it('does not accept plugin authority grants from generic renderer settings IPC', async () => { + store.getSettings.mockReturnValue({ pluginConsents: {}, disabledPlugins: [] }) + store.updateSettings.mockReturnValue({ pluginConsents: {}, disabledPlugins: [] }) + registerSettingsHandlers(store as never) + + const handler = handleMock.mock.calls.find((call) => call[0] === 'settings:set')?.[1] as ( + _event: unknown, + args: unknown + ) => Promise + + await handler(settingsInvokeEvent, { + pluginConsents: { 'orca-samples.demo': 'sha256-forged' }, + disabledPlugins: ['orca-samples.demo'] + }) + + expect(store.updateSettings).toHaveBeenCalledWith( + {}, + { notifyListeners: true, originWebContentsId: 1 } + ) + }) + it('normalizes terminal scrollback row updates and drops legacy byte updates', async () => { store.getSettings.mockReturnValue({ terminalScrollbackRows: 5_000 }) store.updateSettings.mockReturnValue({ terminalScrollbackRows: 50_000 }) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index c4e37b9c01c..0166f4ad5ee 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -37,6 +37,10 @@ function sanitizeRendererSettingsUpdate(args: Partial): Partial< const { terminalScrollbackBytes: _legacyScrollbackBytes, ...sanitizedArgs } = args as LegacyTerminalScrollbackSettingsUpdate void _legacyScrollbackBytes + // Plugin consent and enablement are main-owned authority state. Renderer + // writes must pass the dedicated reviewed-fingerprint handlers. + delete sanitizedArgs.pluginConsents + delete sanitizedArgs.disabledPlugins return sanitizedArgs } diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index d41a92f90ff..43a0fbfbf55 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -95,7 +95,7 @@ import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import { killAllProcessesForWorktree } from '../runtime/worktree-teardown' import { clearProviderPtyState, getLocalPtyProvider, getSshPtyProvider } from './pty' import { findExistingWorktreeSymlinkPaths, removeWorktreeLinkedPaths } from './worktree-symlinks' @@ -987,7 +987,8 @@ function buildDisconnectedDetectedWorktrees( export function registerWorktreeHandlers( mainWindow: BrowserWindow, store: Store, - runtime: OrcaRuntimeService + runtime: OrcaRuntimeService, + options?: { onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { // Remove previously registered handlers so re-register works when macOS re-activates and creates a new window. ipcMain.removeHandler('worktrees:listAll') @@ -1285,6 +1286,13 @@ export function registerWorktreeHandlers( notifyWorktreesChanged(mainWindow, repo.id) } + options?.onWorktreeLifecycle?.({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) + return result }) } @@ -1902,7 +1910,13 @@ export function registerWorktreeHandlers( })() worktreeRemovalsInFlight.set(inFlightKey, { optionsKey, promise: removal }) try { - return await removal + const result = await removal + options?.onWorktreeLifecycle?.({ + kind: 'removed', + worktreeId: args.worktreeId, + path: parseWorktreeId(args.worktreeId).worktreePath + }) + return result } finally { if (worktreeRemovalsInFlight.get(inFlightKey)?.promise === removal) { worktreeRemovalsInFlight.delete(inFlightKey) diff --git a/src/main/jira/adf-markdown.test.ts b/src/main/jira/adf-markdown.test.ts new file mode 100644 index 00000000000..ce6a160b510 --- /dev/null +++ b/src/main/jira/adf-markdown.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest' +import { adfToMarkdownText, collectAdfMediaAttrs } from './adf-markdown' +import { escapeMarkdownLinkDestination } from './adf-media-destination' + +describe('adfToMarkdownText media', () => { + it('keeps a placeholder when media cannot be resolved', () => { + const markdown = adfToMarkdownText({ + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Before' }] }, + { + type: 'mediaSingle', + attrs: { layout: 'center' }, + content: [ + { + type: 'media', + attrs: { + id: 'media-uuid-1', + type: 'file', + collection: 'contentId-1', + alt: 'screenshot.png' + } + } + ] + }, + { type: 'paragraph', content: [{ type: 'text', text: 'After' }] } + ] + }) + + expect(markdown).toBe('Before\n\n*[screenshot.png]*\n\nAfter') + }) + + it('uses the media resolver for file media nodes', () => { + const resolveMedia = vi.fn(() => '![shot.png](data:image/png;base64,abc)') + const markdown = adfToMarkdownText( + { + type: 'doc', + version: 1, + content: [ + { + type: 'mediaSingle', + content: [ + { + type: 'media', + attrs: { id: 'media-1', type: 'file', alt: 'shot.png' } + } + ] + } + ] + }, + { resolveMedia } + ) + + expect(resolveMedia).toHaveBeenCalledWith({ + id: 'media-1', + url: undefined, + alt: 'shot.png', + type: 'file' + }) + expect(markdown).toBe('![shot.png](data:image/png;base64,abc)') + }) + + it('renders external media URLs without a resolver', () => { + const markdown = adfToMarkdownText({ + type: 'doc', + version: 1, + content: [ + { + type: 'mediaSingle', + content: [ + { + type: 'media', + attrs: { + type: 'external', + url: 'https://example.com/diagram.png', + alt: 'diagram' + } + } + ] + } + ] + }) + + expect(markdown).toBe('![diagram](https://example.com/diagram.png)') + }) + + it('renders mediaInline inside paragraphs', () => { + const markdown = adfToMarkdownText( + { + type: 'doc', + version: 1, + content: [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'See ' }, + { + type: 'mediaInline', + attrs: { id: 'inline-1', type: 'file', alt: 'icon.png' } + } + ] + } + ] + }, + { + resolveMedia: () => '![icon.png](data:image/png;base64,xyz)' + } + ) + + expect(markdown).toBe('See ![icon.png](data:image/png;base64,xyz)') + }) + + it('escapes markdown-hostile external media destinations', () => { + const hostile = 'https://cdn.example/x?a=1)![z](https://evil.example/y' + // Pin: encodeURI alone does not encode ) + expect(encodeURI(hostile)).toContain(')') + const safe = escapeMarkdownLinkDestination(hostile) + expect(safe).not.toBeNull() + expect(safe).not.toContain(')') + expect(safe).toContain('%29') + + const markdown = adfToMarkdownText({ + type: 'doc', + version: 1, + content: [ + { + type: 'mediaSingle', + content: [{ type: 'media', attrs: { type: 'external', url: hostile, alt: 'Image' } }] + } + ] + }) + expect(markdown).toContain('%29') + expect(markdown).not.toContain('](https://evil') + }) + + it('preserves existing percent-escapes when encoding destinations', () => { + const url = 'https://cdn.example/path%20with%20space.png' + expect(escapeMarkdownLinkDestination(url)).toBe(url) + }) + + it('collects media attrs in document order', () => { + const attrs = collectAdfMediaAttrs({ + type: 'doc', + content: [ + { type: 'media', attrs: { id: 'a', alt: 'one.png' } }, + { + type: 'paragraph', + content: [{ type: 'mediaInline', attrs: { id: 'b', url: 'https://x.example/y.png' } }] + } + ] + }) + expect(attrs).toEqual([ + { id: 'a', alt: 'one.png' }, + { id: 'b', url: 'https://x.example/y.png' } + ]) + }) +}) diff --git a/src/main/jira/adf-markdown.ts b/src/main/jira/adf-markdown.ts index 2c4eea30ec2..2706e64697e 100644 --- a/src/main/jira/adf-markdown.ts +++ b/src/main/jira/adf-markdown.ts @@ -1,3 +1,5 @@ +import { escapeMarkdownLinkDestination } from './adf-media-destination' + type JiraAdfRecord = Record type MarkdownBlock = { @@ -5,6 +7,20 @@ type MarkdownBlock = { text: string } +export type JiraAdfMediaAttrs = { + id?: string + url?: string + alt?: string + type?: string +} + +/** Returns markdown for a media node (usually `![alt](src)`), or null to fall back. */ +export type JiraAdfMediaResolver = (attrs: JiraAdfMediaAttrs) => string | null + +export type AdfToMarkdownOptions = { + resolveMedia?: JiraAdfMediaResolver +} + function asRecord(value: unknown): JiraAdfRecord { return value && typeof value === 'object' ? (value as JiraAdfRecord) : {} } @@ -41,7 +57,72 @@ function headingLevel(value: unknown): number { return Math.min(Math.max(positiveInteger(value, 1), 1), 6) } -function renderInline(node: unknown): string { +export function escapeMarkdownAlt(text: string): string { + return text.replace(/[[\]]/g, '') +} + +function mediaAttrsFromRecord(record: JiraAdfRecord): JiraAdfMediaAttrs { + const attrs = asRecord(record.attrs) + return { + id: asString(attrs.id) || undefined, + url: asString(attrs.url) || undefined, + alt: asString(attrs.alt) || asString(attrs.name) || undefined, + type: asString(attrs.type) || undefined + } +} + +export function unresolvedMediaPlaceholder(attrs: JiraAdfMediaAttrs): string { + const label = escapeMarkdownAlt(attrs.alt?.trim() || 'Image') + // Why: keep a visible marker when media cannot be downloaded so screenshots + // are not silently dropped from the issue body. + return `*[${label}]*` +} + +/** Collect media attrs in document order (read-only; separate from adfToMarkdownText). */ +export function collectAdfMediaAttrs(value: unknown): JiraAdfMediaAttrs[] { + const collected: JiraAdfMediaAttrs[] = [] + + const walk = (node: unknown): void => { + if (!node || typeof node !== 'object') { + return + } + if (Array.isArray(node)) { + for (const child of node) { + walk(child) + } + return + } + const record = node as JiraAdfRecord + if (record.type === 'media' || record.type === 'mediaInline') { + collected.push(mediaAttrsFromRecord(record)) + } + walk(record.content) + } + + walk(value) + return collected +} + +function renderMediaMarkdown( + record: JiraAdfRecord, + options: AdfToMarkdownOptions | undefined +): string { + const attrs = mediaAttrsFromRecord(record) + const resolved = options?.resolveMedia?.(attrs) + if (resolved) { + return resolved + } + if (attrs.url && /^https?:\/\//i.test(attrs.url)) { + const safeUrl = escapeMarkdownLinkDestination(attrs.url) + if (!safeUrl) { + return unresolvedMediaPlaceholder(attrs) + } + return `![${escapeMarkdownAlt(attrs.alt?.trim() || 'Image')}](${safeUrl})` + } + return unresolvedMediaPlaceholder(attrs) +} + +function renderInline(node: unknown, options?: AdfToMarkdownOptions): string { if (!node) { return '' } @@ -49,7 +130,7 @@ function renderInline(node: unknown): string { return node } if (Array.isArray(node)) { - return node.map(renderInline).join('') + return node.map((child) => renderInline(child, options)).join('') } if (typeof node !== 'object') { return '' @@ -62,6 +143,11 @@ function renderInline(node: unknown): string { if (record.type === 'hardBreak') { return '\n' } + // Why: Jira pastes screenshots as media/mediaInline ADF nodes; without this + // branch they collapse to empty strings and disappear from the UI. + if (record.type === 'media' || record.type === 'mediaInline') { + return renderMediaMarkdown(record, options) + } const attrs = asRecord(record.attrs) const fallbackText = asString(attrs.text) || asString(attrs.shortName) || asString(attrs.url) @@ -69,7 +155,7 @@ function renderInline(node: unknown): string { return fallbackText } - return renderInline(record.content) + return renderInline(record.content, options) } function joinBlocks(blocks: MarkdownBlock[]): string { @@ -79,14 +165,14 @@ function joinBlocks(blocks: MarkdownBlock[]): string { .join('\n\n') } -function renderBlocks(content: unknown): MarkdownBlock[] { +function renderBlocks(content: unknown, options?: AdfToMarkdownOptions): MarkdownBlock[] { return asArray(content) - .map(renderBlock) + .map((node) => renderBlock(node, options)) .filter((block) => block.text.length > 0) } -function renderListItem(node: unknown, prefix: string): string { - const blocks = renderBlocks(asRecord(node).content) +function renderListItem(node: unknown, prefix: string, options?: AdfToMarkdownOptions): string { + const blocks = renderBlocks(asRecord(node).content, options) if (blocks.length === 0) { return prefix.trimEnd() } @@ -114,20 +200,24 @@ function renderListItem(node: unknown, prefix: string): string { return lines.join('\n') } -function renderList(record: JiraAdfRecord, ordered: boolean): string { +function renderList( + record: JiraAdfRecord, + ordered: boolean, + options?: AdfToMarkdownOptions +): string { const start = ordered ? positiveInteger(asRecord(record.attrs).order, 1) : 1 return asArray(record.content) - .map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- ')) + .map((item, index) => renderListItem(item, ordered ? `${start + index}. ` : '- ', options)) .join('\n') } -function renderCodeBlock(record: JiraAdfRecord): MarkdownBlock { - const text = renderInline(record.content).replace(/\n$/, '') +function renderCodeBlock(record: JiraAdfRecord, options?: AdfToMarkdownOptions): MarkdownBlock { + const text = renderInline(record.content, options).replace(/\n$/, '') return { kind: 'block', text: ['```', text, '```'].join('\n') } } -function renderBlockquote(record: JiraAdfRecord): MarkdownBlock { - const text = joinBlocks(renderBlocks(record.content)) +function renderBlockquote(record: JiraAdfRecord, options?: AdfToMarkdownOptions): MarkdownBlock { + const text = joinBlocks(renderBlocks(record.content, options)) return { kind: 'block', text: text @@ -137,12 +227,12 @@ function renderBlockquote(record: JiraAdfRecord): MarkdownBlock { } } -function renderBlock(node: unknown): MarkdownBlock { +function renderBlock(node: unknown, options?: AdfToMarkdownOptions): MarkdownBlock { if (typeof node === 'string') { return { kind: 'block', text: node } } if (Array.isArray(node)) { - return { kind: 'block', text: joinBlocks(renderBlocks(node)) } + return { kind: 'block', text: joinBlocks(renderBlocks(node, options)) } } if (!node || typeof node !== 'object') { return { kind: 'block', text: '' } @@ -151,41 +241,54 @@ function renderBlock(node: unknown): MarkdownBlock { const record = node as JiraAdfRecord const type = asString(record.type) if (type === 'doc') { - return { kind: 'block', text: joinBlocks(renderBlocks(record.content)) } + return { kind: 'block', text: joinBlocks(renderBlocks(record.content, options)) } } if (type === 'paragraph') { - return { kind: 'block', text: renderInline(record.content) } + return { kind: 'block', text: renderInline(record.content, options) } } if (type === 'heading') { const prefix = '#'.repeat(headingLevel(asRecord(record.attrs).level)) - return { kind: 'block', text: `${prefix} ${renderInline(record.content).trim()}`.trim() } + return { + kind: 'block', + text: `${prefix} ${renderInline(record.content, options).trim()}`.trim() + } } if (type === 'bulletList') { // Why: Orca renders Jira bodies as Markdown, so ADF list containers need // concrete list markers instead of newline-only flattened text. - return { kind: 'list', text: renderList(record, false) } + return { kind: 'list', text: renderList(record, false, options) } } if (type === 'orderedList') { - return { kind: 'list', text: renderList(record, true) } + return { kind: 'list', text: renderList(record, true, options) } } if (type === 'listItem') { - return { kind: 'list', text: renderListItem(record, '- ') } + return { kind: 'list', text: renderListItem(record, '- ', options) } } if (type === 'codeBlock') { - return renderCodeBlock(record) + return renderCodeBlock(record, options) } if (type === 'blockquote') { - return renderBlockquote(record) + return renderBlockquote(record, options) } if (type === 'rule') { return { kind: 'block', text: '---' } } + if (type === 'mediaSingle' || type === 'mediaGroup') { + const mediaMarkdown = joinBlocks(renderBlocks(record.content, options)) + return { kind: 'block', text: mediaMarkdown } + } + if (type === 'media' || type === 'mediaInline') { + return { kind: 'block', text: renderMediaMarkdown(record, options) } + } - return { kind: 'block', text: joinBlocks(renderBlocks(record.content)) || renderInline(record) } + return { + kind: 'block', + text: joinBlocks(renderBlocks(record.content, options)) || renderInline(record, options) + } } -export function adfToMarkdownText(value: unknown): string { - return renderBlock(value) +export function adfToMarkdownText(value: unknown, options?: AdfToMarkdownOptions): string { + return renderBlock(value, options) .text.replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim() diff --git a/src/main/jira/adf-media-destination.ts b/src/main/jira/adf-media-destination.ts new file mode 100644 index 00000000000..e92cce03083 --- /dev/null +++ b/src/main/jira/adf-media-destination.ts @@ -0,0 +1,56 @@ +// Why: markdown image destinations close at the first unescaped `)`. encodeURI +// leaves `()` alone, so Jira-controlled external URLs must be destination-safe. + +const MARKDOWN_DESTINATION_HOSTILE = new Set(['(', ')', '[', ']', '<', '>', '"', "'", '`', '\\']) + +function isMarkdownDestinationHostile(char: string): boolean { + if (MARKDOWN_DESTINATION_HOSTILE.has(char)) { + return true + } + const code = char.charCodeAt(0) + return code <= 0x20 +} + +function percentEncodeUtf8Char(char: string): string { + return Array.from( + new TextEncoder().encode(char), + (byte) => `%${byte.toString(16).toUpperCase().padStart(2, '0')}` + ).join('') +} + +/** Percent-encode markdown-hostile destination chars without double-encoding `%HH`. */ +export function escapeMarkdownLinkDestination(url: string): string | null { + if (!/^https?:\/\//i.test(url)) { + return null + } + + let encoded = '' + for (let i = 0; i < url.length; ) { + const char = url[i] ?? '' + if (char === '%') { + const hex = url.slice(i + 1, i + 3) + if (/^[0-9a-fA-F]{2}$/.test(hex)) { + encoded += `%${hex}` + i += 3 + continue + } + encoded += '%25' + i += 1 + continue + } + if (isMarkdownDestinationHostile(char)) { + encoded += percentEncodeUtf8Char(char) + i += 1 + continue + } + encoded += char + i += 1 + } + + for (const char of encoded) { + if (isMarkdownDestinationHostile(char)) { + return null + } + } + return encoded +} diff --git a/src/main/jira/attachment-discovery.ts b/src/main/jira/attachment-discovery.ts new file mode 100644 index 00000000000..945acbac247 --- /dev/null +++ b/src/main/jira/attachment-discovery.ts @@ -0,0 +1,99 @@ +import type { JiraAdfMediaAttrs } from './adf-markdown' +import { MAX_IMAGES, parseImageAttachmentMetas } from './attachment-meta' + +/** + * Pull attachment content IDs from Jira rendered HTML in document order. + * Why: thumbnail paths use the same numeric attachment id as content URLs. + */ +export function extractAttachmentContentIdsFromHtml(html: string | undefined | null): string[] { + if (!html) { + return [] + } + const ids: string[] = [] + const seen = new Set() + // content, secure/attachment, thumbnail, and rest thumbnail forms + const pattern = + /\/(?:rest\/api\/\d+\/attachment\/(?:content|thumbnail)|secure\/(?:attachment|thumbnail))\/(\d+)(?:\/|\b|"|'|\?)/gi + let match: RegExpExecArray | null + while ((match = pattern.exec(html)) !== null) { + const id = match[1] + if (!id || seen.has(id)) { + continue + } + seen.add(id) + ids.push(id) + } + return ids +} + +/** Prefer attachment-needing media (no external https URL) for discovery fallback. */ +export function selectPreferredAttachmentIds(args: { + renderedHtmlIds: string[] + attachmentField: unknown + mediaAttrs: readonly JiraAdfMediaAttrs[] +}): { preferredIds: string[]; fallbackRan: boolean; needCount: number } { + const needing = args.mediaAttrs.filter((attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url))) + const needCount = needing.length + if (needCount === 0) { + // Why: no attachment-needing ADF media — skip downloads (do not sweep HTML-only). + return { preferredIds: [], fallbackRan: false, needCount: 0 } + } + + const preferredIds = [...args.renderedHtmlIds] + const taken = new Set(preferredIds) + let fallbackRan = false + + if (needCount > preferredIds.length) { + const metas = parseImageAttachmentMetas(args.attachmentField) + // Why: multiple Jira screenshots often share image.png — assign next unused meta per node. + for (const node of needing) { + if (preferredIds.length >= MAX_IMAGES) { + break + } + const alt = (node.alt ?? '').trim() + if (!alt) { + continue + } + const altKey = alt.toLowerCase() + const meta = metas.find( + (candidate) => candidate.filename.toLowerCase() === altKey && !taken.has(candidate.id) + ) + if (!meta) { + continue + } + taken.add(meta.id) + preferredIds.push(meta.id) + fallbackRan = true + } + } + + return { + preferredIds: preferredIds.slice(0, MAX_IMAGES), + fallbackRan, + needCount + } +} + +export function warnIfMediaResolutionIncomplete(args: { + siteId: string + issueKey: string + needCount: number + preferredIdCount: number + resolvedCount: number + fallbackRan: boolean +}): void { + if (args.needCount <= 0) { + return + } + if (args.resolvedCount >= args.needCount) { + return + } + console.warn('[jira] inline image resolution incomplete', { + siteId: args.siteId, + issueKey: args.issueKey, + needCount: args.needCount, + preferredIdCount: args.preferredIdCount, + resolvedCount: args.resolvedCount, + fallbackRan: args.fallbackRan + }) +} diff --git a/src/main/jira/attachment-image-cache.test.ts b/src/main/jira/attachment-image-cache.test.ts new file mode 100644 index 00000000000..4f213327c7f --- /dev/null +++ b/src/main/jira/attachment-image-cache.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + _getAttachmentImageCacheSize, + _resetAttachmentImageCache, + clearAttachmentImagesForSite, + getCachedAttachmentDataUrl, + loadAttachmentDataUrlWithCache, + setCachedAttachmentDataUrl +} from './attachment-image-cache' + +describe('attachment image cache', () => { + beforeEach(() => { + _resetAttachmentImageCache() + }) + + it('returns cached data urls and isolates sites', () => { + setCachedAttachmentDataUrl({ + siteId: 'a', + attachmentId: '1', + dataUrl: 'data:image/png;base64,AA==', + byteSize: 1 + }) + setCachedAttachmentDataUrl({ + siteId: 'b', + attachmentId: '1', + dataUrl: 'data:image/png;base64,BB==', + byteSize: 1 + }) + expect(getCachedAttachmentDataUrl('a', '1')).toBe('data:image/png;base64,AA==') + expect(getCachedAttachmentDataUrl('b', '1')).toBe('data:image/png;base64,BB==') + clearAttachmentImagesForSite('a') + expect(getCachedAttachmentDataUrl('a', '1')).toBeNull() + expect(getCachedAttachmentDataUrl('b', '1')).toBe('data:image/png;base64,BB==') + }) + + it('singleflights concurrent loads and does not cache failures', async () => { + let calls = 0 + let resolveLoad: (value: { dataUrl: string; byteSize: number } | null) => void = () => {} + const load = () => + new Promise<{ dataUrl: string; byteSize: number } | null>((resolve) => { + calls += 1 + resolveLoad = resolve + }) + + const p1 = loadAttachmentDataUrlWithCache({ siteId: 's', attachmentId: '1', load }) + const p2 = loadAttachmentDataUrlWithCache({ siteId: 's', attachmentId: '1', load }) + expect(calls).toBe(1) + resolveLoad(null) + expect(await p1).toBeNull() + expect(await p2).toBeNull() + expect(getCachedAttachmentDataUrl('s', '1')).toBeNull() + + const p3 = loadAttachmentDataUrlWithCache({ + siteId: 's', + attachmentId: '1', + load: async () => ({ dataUrl: 'data:image/png;base64,OK==', byteSize: 2 }) + }) + expect(await p3).toBe('data:image/png;base64,OK==') + expect(_getAttachmentImageCacheSize()).toBe(1) + }) +}) diff --git a/src/main/jira/attachment-image-cache.ts b/src/main/jira/attachment-image-cache.ts new file mode 100644 index 00000000000..d1e0f42a571 --- /dev/null +++ b/src/main/jira/attachment-image-cache.ts @@ -0,0 +1,169 @@ +// Why: description and comments fetch attachments independently; comments also +// refetch after every post. Cache finished data URLs in main so the second path +// does not re-download or re-base64 the same attachment bytes. + +const CACHE_TTL_MS = 30 * 60_000 +const MAX_CACHE_ENTRIES = 96 +const MAX_CACHE_BYTES = 24 * 1024 * 1024 + +type CacheEntry = { + dataUrl: string + byteSize: number + storedAt: number +} + +const cache = new Map() +const inFlight = new Map>() +// Why: mid-flight downloads must not repopulate cache after disconnect/clearToken. +let cacheEpoch = 0 +const siteEpoch = new Map() + +function cacheKey(siteId: string, attachmentId: string): string { + return `${siteId}::${attachmentId}` +} + +function currentEpoch(siteId: string): number { + return (siteEpoch.get(siteId) ?? 0) + cacheEpoch +} + +function pruneExpired(now = Date.now()): void { + for (const [key, entry] of cache) { + if (now - entry.storedAt >= CACHE_TTL_MS) { + cache.delete(key) + } + } +} + +function totalCachedBytes(): number { + let total = 0 + for (const entry of cache.values()) { + total += entry.byteSize + } + return total +} + +function evictUntilWithinBounds(): void { + while (cache.size > MAX_CACHE_ENTRIES || totalCachedBytes() > MAX_CACHE_BYTES) { + const oldestKey = cache.keys().next().value + if (oldestKey === undefined) { + break + } + cache.delete(oldestKey) + } +} + +export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string): string | null { + pruneExpired() + const key = cacheKey(siteId, attachmentId) + const entry = cache.get(key) + if (!entry) { + return null + } + if (Date.now() - entry.storedAt >= CACHE_TTL_MS) { + cache.delete(key) + return null + } + // Why: delete-then-set refreshes insertion order for LRU-style eviction. + cache.delete(key) + cache.set(key, entry) + return entry.dataUrl +} + +export function setCachedAttachmentDataUrl(args: { + siteId: string + attachmentId: string + dataUrl: string + byteSize: number +}): void { + pruneExpired() + const key = cacheKey(args.siteId, args.attachmentId) + const entry: CacheEntry = { + dataUrl: args.dataUrl, + byteSize: args.byteSize, + storedAt: Date.now() + } + cache.delete(key) + cache.set(key, entry) + evictUntilWithinBounds() +} + +/** + * Singleflight loader: concurrent cold misses for the same attachment share one + * download. Failed loads are not cached so a later retry can succeed. + */ +export async function loadAttachmentDataUrlWithCache(args: { + siteId: string + attachmentId: string + load: () => Promise<{ dataUrl: string; byteSize: number } | null> +}): Promise { + const cached = getCachedAttachmentDataUrl(args.siteId, args.attachmentId) + if (cached) { + return cached + } + + const key = cacheKey(args.siteId, args.attachmentId) + const existing = inFlight.get(key) + if (existing) { + return existing + } + + const epochAtStart = currentEpoch(args.siteId) + const promise = (async (): Promise => { + try { + const loaded = await args.load() + if (!loaded) { + return null + } + // Why: return bytes to the waiter but skip cache if site was cleared mid-flight. + if (currentEpoch(args.siteId) === epochAtStart) { + setCachedAttachmentDataUrl({ + siteId: args.siteId, + attachmentId: args.attachmentId, + dataUrl: loaded.dataUrl, + byteSize: loaded.byteSize + }) + } + return loaded.dataUrl + } finally { + inFlight.delete(key) + } + })() + + inFlight.set(key, promise) + return promise +} + +export function clearAttachmentImagesForSite(siteId?: string): void { + if (siteId == null || siteId === '') { + cache.clear() + inFlight.clear() + cacheEpoch += 1 + siteEpoch.clear() + return + } + siteEpoch.set(siteId, (siteEpoch.get(siteId) ?? 0) + 1) + const prefix = `${siteId}::` + for (const key of cache.keys()) { + if (key.startsWith(prefix)) { + cache.delete(key) + } + } + for (const key of inFlight.keys()) { + if (key.startsWith(prefix)) { + inFlight.delete(key) + } + } +} + +/** @internal — test-only */ +export function _resetAttachmentImageCache(): void { + cache.clear() + inFlight.clear() + cacheEpoch = 0 + siteEpoch.clear() +} + +/** @internal — test-only */ +export function _getAttachmentImageCacheSize(): number { + return cache.size +} diff --git a/src/main/jira/attachment-images.test.ts b/src/main/jira/attachment-images.test.ts new file mode 100644 index 00000000000..e160f69093b --- /dev/null +++ b/src/main/jira/attachment-images.test.ts @@ -0,0 +1,351 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { JiraClientForSite } from './client' + +const { jiraRequestBinaryMock } = vi.hoisted(() => ({ + jiraRequestBinaryMock: vi.fn() +})) + +vi.mock('./client', () => ({ + jiraRequestBinary: (...args: unknown[]) => jiraRequestBinaryMock(...args), + apiBasePath: (site: { authType?: string }) => + site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', + JiraApiError: class JiraApiError extends Error { + status: number | null + constructor(message: string, status: number | null = null) { + super(message) + this.status = status + } + } +})) + +function makeEntry(): JiraClientForSite { + return { + site: { + id: 'site-1', + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + displayName: 'Example Jira', + accountId: 'account-1' + }, + authorization: 'Basic token' + } +} + +describe('attachment image helpers', () => { + beforeEach(async () => { + jiraRequestBinaryMock.mockReset() + const { _resetAttachmentImageCache } = await import('./attachment-image-cache') + _resetAttachmentImageCache() + }) + + it('extracts attachment content ids from rendered HTML in order', async () => { + const { extractAttachmentContentIdsFromHtml } = await import('./attachment-discovery') + const ids = extractAttachmentContentIdsFromHtml(` +

    intro

    + + + + `) + expect(ids).toEqual(['101', '202']) + }) + + it('extracts thumbnail attachment ids', async () => { + const { extractAttachmentContentIdsFromHtml } = await import('./attachment-discovery') + expect( + extractAttachmentContentIdsFromHtml( + '' + ) + ).toEqual(['10001']) + expect( + extractAttachmentContentIdsFromHtml( + '' + ) + ).toEqual(['10002']) + }) + + it('downloads image attachments and builds a media resolver', async () => { + const pngBytes = Uint8Array.from([137, 80, 78, 71]) + jiraRequestBinaryMock.mockResolvedValue({ + data: pngBytes.buffer, + contentType: 'image/png' + }) + + const { createMediaMarkdownResolver, loadIssueImageAttachments } = + await import('./attachment-images') + + const images = await loadIssueImageAttachments( + makeEntry(), + [ + { + id: '101', + filename: 'shot.png', + mimeType: 'image/png', + size: 4 + }, + { + id: '202', + filename: 'notes.txt', + mimeType: 'text/plain', + size: 12 + } + ], + ['101'] + ) + + expect(images).toHaveLength(1) + expect(images[0]?.id).toBe('101') + expect(images[0]?.dataUrl.startsWith('data:image/png;base64,')).toBe(true) + expect(jiraRequestBinaryMock).toHaveBeenCalledWith( + expect.anything(), + 'https://example.atlassian.net/rest/api/3/attachment/content/101?redirect=false' + ) + + const resolve = createMediaMarkdownResolver(images, ['101']) + const resolved = `![shot.png](${images[0]?.dataUrl})` + expect(resolve({ id: 'media-uuid', type: 'file', alt: 'shot.png' })).toBe(resolved) + expect(resolve({ id: 'media-uuid', type: 'file' })).toBe(resolved) + expect(resolve({ id: 'media-uuid-2', type: 'file' })).toBeNull() + }) + + it('pairs shared alt filenames to distinct attachments then falls through', async () => { + const { createMediaMarkdownResolver } = await import('./attachment-images') + const images = [ + { + id: '1', + filename: 'image.png', + mimeType: 'image/png', + byteSize: 1, + dataUrl: 'data:image/png;base64,AA==' + }, + { + id: '2', + filename: 'other.png', + mimeType: 'image/png', + byteSize: 1, + dataUrl: 'data:image/png;base64,BB==' + } + ] + const resolve = createMediaMarkdownResolver(images, ['1', '2']) + expect(resolve({ id: 'm1', alt: 'image.png' })).toBe('![image.png](data:image/png;base64,AA==)') + // Exhausted filename match must not re-emit image 1 + expect(resolve({ id: 'm2', alt: 'image.png' })).toBe('![other.png](data:image/png;base64,BB==)') + }) + + it('does not re-emit an already consumed attachment for a third shared-alt node', async () => { + const { createMediaMarkdownResolver } = await import('./attachment-images') + const images = [ + { + id: '1', + filename: 'image.png', + mimeType: 'image/png', + byteSize: 1, + dataUrl: 'data:image/png;base64,AA==' + }, + { + id: '2', + filename: 'image.png', + mimeType: 'image/png', + byteSize: 1, + dataUrl: 'data:image/png;base64,BB==' + } + ] + const resolve = createMediaMarkdownResolver(images, ['1', '2']) + expect(resolve({ id: 'm1', alt: 'image.png' })).toContain('AA==') + expect(resolve({ id: 'm2', alt: 'image.png' })).toContain('BB==') + expect(resolve({ id: 'm3', alt: 'image.png' })).toBeNull() + }) + + it('escapes hostile external media URLs instead of injecting markdown', async () => { + const { createMediaMarkdownResolver } = await import('./attachment-images') + const resolve = createMediaMarkdownResolver([], []) + const hostile = 'https://evil.example/x?a=1)![z](javascript:alert(1))' + const out = resolve({ url: hostile, alt: 'Image' }) + expect(out).not.toContain('](javascript:') + expect(out).toMatch(/^!\[[^\]]*\]\(https:\/\/evil\.example/) + // encodeURI leaves ) unencoded — pin the bug class + expect(encodeURI(hostile)).toContain(')') + expect(out).not.toBe(`![Image](${hostile})`) + }) + + it('returns placeholder for external URLs that remain hostile after encode', async () => { + const { createMediaMarkdownResolver } = await import('./attachment-images') + const resolve = createMediaMarkdownResolver([], []) + // non-http rejected + expect(resolve({ url: 'javascript:alert(1)', alt: 'x' })).toBe('*[x]*') + }) + + it('selects preferred ids via Option A filename fallback without sweeping all attachments', async () => { + const { selectPreferredAttachmentIds } = await import('./attachment-discovery') + const attachments = [ + { id: '1', filename: 'a.png', mimeType: 'image/png', size: 1 }, + { id: '2', filename: 'b.png', mimeType: 'image/png', size: 1 }, + { id: '3', filename: 'unrelated.png', mimeType: 'image/png', size: 1 } + ] + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: [], + attachmentField: attachments, + mediaAttrs: [{ alt: 'a.png' }, { alt: 'b.png' }] + }) + expect(selection.preferredIds).toEqual(['1', '2']) + expect(selection.fallbackRan).toBe(true) + expect(selection.needCount).toBe(2) + + const noMedia = selectPreferredAttachmentIds({ + renderedHtmlIds: [], + attachmentField: attachments, + mediaAttrs: [] + }) + expect(noMedia.preferredIds).toEqual([]) + expect(noMedia.needCount).toBe(0) + }) + + it('Option A unions multiple same-filename attachments for repeated alts', async () => { + const { selectPreferredAttachmentIds } = await import('./attachment-discovery') + const attachments = [ + { id: '1', filename: 'image.png', mimeType: 'image/png', size: 1 }, + { id: '2', filename: 'image.png', mimeType: 'image/png', size: 1 } + ] + const zeroHtml = selectPreferredAttachmentIds({ + renderedHtmlIds: [], + attachmentField: attachments, + mediaAttrs: [{ alt: 'image.png' }, { alt: 'image.png' }] + }) + expect(zeroHtml.preferredIds).toEqual(['1', '2']) + expect(zeroHtml.fallbackRan).toBe(true) + + const partialHtml = selectPreferredAttachmentIds({ + renderedHtmlIds: ['1'], + attachmentField: attachments, + mediaAttrs: [{ alt: 'image.png' }, { alt: 'image.png' }] + }) + expect(partialHtml.preferredIds).toEqual(['1', '2']) + }) + + it('downloads only referenced attachments after prioritizing the complete metadata list', async () => { + jiraRequestBinaryMock.mockResolvedValue({ + data: Uint8Array.from([1]).buffer, + contentType: 'image/png' + }) + const { loadIssueImageAttachments } = await import('./attachment-images') + const attachments = Array.from({ length: 13 }, (_, index) => ({ + id: String(index + 1), + filename: `${index + 1}.png`, + mimeType: 'image/png', + size: 1 + })) + + const images = await loadIssueImageAttachments(makeEntry(), attachments, ['13']) + + expect(images.map((image) => image.id)).toEqual(['13']) + expect(jiraRequestBinaryMock).toHaveBeenCalledTimes(1) + expect(jiraRequestBinaryMock).toHaveBeenCalledWith( + expect.anything(), + 'https://example.atlassian.net/rest/api/3/attachment/content/13?redirect=false' + ) + }) + + it('does not download attachments when rendered content references none', async () => { + const { loadIssueImageAttachments } = await import('./attachment-images') + + await expect( + loadIssueImageAttachments( + makeEntry(), + [{ id: '1', filename: 'unrelated.png', mimeType: 'image/png', size: 1 }], + [] + ) + ).resolves.toEqual([]) + expect(jiraRequestBinaryMock).not.toHaveBeenCalled() + }) + + it('uses the attachment content URI supplied by self-hosted Jira', async () => { + jiraRequestBinaryMock.mockResolvedValue({ + data: Uint8Array.from([1]).buffer, + contentType: 'image/png' + }) + const entry = makeEntry() + entry.site = { + ...entry.site, + siteUrl: 'https://jira.example.com/jira', + authType: 'server' + } + const { loadIssueImageAttachments } = await import('./attachment-images') + + await loadIssueImageAttachments( + entry, + [ + { + id: '42', + filename: 'server.png', + mimeType: 'image/png', + size: 1, + content: 'https://jira.example.com/jira/secure/attachment/42/server.png' + } + ], + ['42'] + ) + + expect(jiraRequestBinaryMock).toHaveBeenCalledWith( + expect.anything(), + 'https://jira.example.com/jira/secure/attachment/42/server.png' + ) + }) + + it('skips oversized and non-image attachments', async () => { + const { parseImageAttachmentMetas } = await import('./attachment-meta') + expect( + parseImageAttachmentMetas([ + { id: '1', filename: 'big.png', mimeType: 'image/png', size: 20 * 1024 * 1024 }, + { id: '2', filename: 'icon.svg', mimeType: 'image/svg+xml', size: 100 }, + { id: '3', filename: 'ok.jpg', mimeType: 'image/jpeg', size: 100 } + ]) + ).toEqual([{ id: '3', filename: 'ok.jpg', mimeType: 'image/jpeg', size: 100 }]) + }) + + it('serves a second load of the same attachment from cache', async () => { + jiraRequestBinaryMock.mockResolvedValue({ + data: Uint8Array.from([1, 2, 3]).buffer, + contentType: 'image/png' + }) + const { loadIssueImageAttachments } = await import('./attachment-images') + const entry = makeEntry() + const field = [{ id: '9', filename: 'c.png', mimeType: 'image/png', size: 3 }] + await loadIssueImageAttachments(entry, field, ['9']) + await loadIssueImageAttachments(entry, field, ['9']) + expect(jiraRequestBinaryMock).toHaveBeenCalledTimes(1) + }) + + it('singleflights concurrent cold misses for the same attachment', async () => { + let resolveDownload: (value: { data: ArrayBuffer; contentType: string }) => void = () => {} + jiraRequestBinaryMock.mockImplementation( + () => + new Promise((resolve) => { + resolveDownload = resolve + }) + ) + const { loadIssueImageAttachments } = await import('./attachment-images') + const entry = makeEntry() + const field = [{ id: '7', filename: 's.png', mimeType: 'image/png', size: 1 }] + const p1 = loadIssueImageAttachments(entry, field, ['7']) + const p2 = loadIssueImageAttachments(entry, field, ['7']) + resolveDownload({ data: Uint8Array.from([9]).buffer, contentType: 'image/png' }) + const [a, b] = await Promise.all([p1, p2]) + expect(a).toHaveLength(1) + expect(b).toHaveLength(1) + expect(jiraRequestBinaryMock).toHaveBeenCalledTimes(1) + }) + + it('warns when media resolution is incomplete', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { warnIfMediaResolutionIncomplete } = await import('./attachment-discovery') + warnIfMediaResolutionIncomplete({ + siteId: 's', + issueKey: 'ABC-1', + needCount: 2, + preferredIdCount: 1, + resolvedCount: 0, + fallbackRan: true + }) + expect(warn).toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/src/main/jira/attachment-images.ts b/src/main/jira/attachment-images.ts new file mode 100644 index 00000000000..ce2e682b872 --- /dev/null +++ b/src/main/jira/attachment-images.ts @@ -0,0 +1,252 @@ +import type { JiraClientForSite } from './client' +import { JiraApiError, apiBasePath, jiraRequestBinary } from './client' +import type { JiraAdfMediaAttrs, JiraAdfMediaResolver } from './adf-markdown' +import { escapeMarkdownAlt, unresolvedMediaPlaceholder } from './adf-markdown' +import { escapeMarkdownLinkDestination } from './adf-media-destination' +import { loadAttachmentDataUrlWithCache } from './attachment-image-cache' +import { + MAX_IMAGE_BYTES, + MAX_IMAGES, + MAX_TOTAL_IMAGE_BYTES, + parseImageAttachmentMetas, + isImageMimeType, + type AttachmentMeta +} from './attachment-meta' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' + +const DOWNLOAD_CONCURRENCY = 3 + +export type JiraImageAttachment = { + id: string + filename: string + mimeType: string + byteSize: number + dataUrl: string +} + +async function downloadImageAttachment( + client: JiraClientForSite, + meta: AttachmentMeta +): Promise { + if (!meta.contentUrl && client.site.authType === 'server') { + // Server/DC exposes attachment bytes through the metadata-provided content URI. + return null + } + + const dataUrl = await loadAttachmentDataUrlWithCache({ + siteId: client.site.id, + attachmentId: meta.id, + load: async () => { + try { + const contentUrl = meta.contentUrl + ? new URL(meta.contentUrl, `${client.site.siteUrl}/`) + : // Why: Cloud fallback uses apiBasePath so Server sites that lack contentUrl + // still hit /rest/api/2 if ever called; Server still requires content metadata. + new URL( + `${apiBasePath(client.site)}/attachment/content/${encodeURIComponent(meta.id)}`, + client.site.siteUrl + ) + if (/\/rest\/api\/(?:2|3)\/attachment\/content\/[^/]+$/i.test(contentUrl.pathname)) { + contentUrl.searchParams.set('redirect', 'false') + } + const binary = await jiraRequestBinary(client, contentUrl.toString()) + if (binary.data.byteLength === 0 || binary.data.byteLength > MAX_IMAGE_BYTES) { + return null + } + const contentType = binary.contentType.split(';')[0]?.trim() || meta.mimeType + if (!isImageMimeType(contentType) && !isImageMimeType(meta.mimeType)) { + return null + } + const mime = isImageMimeType(contentType) ? contentType : meta.mimeType + const base64 = Buffer.from(binary.data).toString('base64') + return { + dataUrl: `data:${mime};base64,${base64}`, + byteSize: binary.data.byteLength + } + } catch (error) { + // Why: one bad attachment should not blank the whole issue description. + if (error instanceof JiraApiError && error.status === 404) { + return null + } + console.warn('[jira] attachment image download failed:', meta.id, error) + return null + } + } + }) + + if (!dataUrl) { + return null + } + + const mimeMatch = /^data:([^;]+);base64,/.exec(dataUrl) + const mime = mimeMatch?.[1] || meta.mimeType + // Approximate byte size from base64 payload when served from cache. + const base64Part = dataUrl.includes(',') ? dataUrl.slice(dataUrl.indexOf(',') + 1) : '' + const byteSize = Math.floor((base64Part.length * 3) / 4) + + return { + id: meta.id, + filename: meta.filename, + mimeType: mime, + byteSize, + dataUrl + } +} + +export async function loadIssueImageAttachments( + client: JiraClientForSite, + attachmentField: unknown, + preferredIds: string[] = [] +): Promise { + const metas = parseImageAttachmentMetas(attachmentField) + if (metas.length === 0 || preferredIds.length === 0) { + return [] + } + + const byId = new Map(metas.map((meta) => [meta.id, meta])) + const ordered: AttachmentMeta[] = [] + const used = new Set() + + for (const id of preferredIds) { + const meta = byId.get(id) + if (meta && !used.has(meta.id)) { + ordered.push(meta) + used.add(meta.id) + } + } + + // Why: pre-select by declared size so concurrent downloads do not fetch bodies + // that will be dropped by the total budget after completion. + const toDownload: AttachmentMeta[] = [] + let plannedBytes = 0 + for (const meta of ordered.slice(0, MAX_IMAGES)) { + if (meta.size > 0 && plannedBytes + meta.size > MAX_TOTAL_IMAGE_BYTES) { + continue + } + toDownload.push(meta) + if (meta.size > 0) { + plannedBytes += meta.size + } + } + + const downloaded = await mapWithConcurrency(toDownload, DOWNLOAD_CONCURRENCY, (meta) => + downloadImageAttachment(client, meta) + ) + + const images: JiraImageAttachment[] = [] + let totalBytes = 0 + for (const image of downloaded) { + if (!image) { + continue + } + if (totalBytes + image.byteSize > MAX_TOTAL_IMAGE_BYTES) { + continue + } + totalBytes += image.byteSize + images.push(image) + } + return images +} + +export type MediaResolutionStats = { + /** Attachment-needing media nodes that successfully resolved to a data: image. */ + attachmentResolvedCount: number +} + +export function createMediaMarkdownResolver( + images: readonly JiraImageAttachment[], + preferredAttachmentIds: readonly string[] = [], + stats?: MediaResolutionStats +): JiraAdfMediaResolver { + const byId = new Map(images.map((image) => [image.id, image])) + const byFilename = new Map() + const resolvedByMediaId = new Map() + for (const image of images) { + const key = image.filename.toLowerCase() + const list = byFilename.get(key) ?? [] + list.push(image) + byFilename.set(key, list) + } + + // Prefer document-order attachment IDs from rendered HTML, then remaining images. + const queue: JiraImageAttachment[] = [] + const queued = new Set() + for (const id of preferredAttachmentIds) { + const image = byId.get(id) + if (image && !queued.has(image.id)) { + queue.push(image) + queued.add(image.id) + } + } + for (const image of images) { + if (!queued.has(image.id)) { + queue.push(image) + queued.add(image.id) + } + } + + const take = (image: JiraImageAttachment | undefined): string | null => { + if (!image) { + return null + } + const index = queue.findIndex((entry) => entry.id === image.id) + // Why: already-consumed images must not re-emit; fall through to positional pairing. + if (index < 0) { + return null + } + queue.splice(index, 1) + return `![${escapeMarkdownAlt(image.filename)}](${image.dataUrl})` + } + + return (attrs: JiraAdfMediaAttrs): string | null => { + if (attrs.id) { + const cached = resolvedByMediaId.get(attrs.id) + if (cached) { + if (stats && !cached.startsWith('*[') && cached.includes('data:')) { + stats.attachmentResolvedCount += 1 + } + return cached + } + } + const alt = attrs.alt?.trim() || 'Image' + if (attrs.url) { + // Why: return placeholder (not null) so non-http / hostile externals do not + // fall through to positional attachment pairing. + if (!/^https?:\/\//i.test(attrs.url)) { + return unresolvedMediaPlaceholder({ ...attrs, alt }) + } + const safeUrl = escapeMarkdownLinkDestination(attrs.url) + if (!safeUrl) { + return unresolvedMediaPlaceholder({ ...attrs, alt }) + } + // External success does not count toward attachment needCount. + return `![${escapeMarkdownAlt(alt)}](${safeUrl})` + } + + let resolved: string | null = null + // Why: ADF media IDs are Media Service UUIDs, not attachment IDs — skip byId + // lookup on attrs.id against attachment map (they never match). + if (attrs.alt?.trim()) { + const matches = byFilename.get(attrs.alt.trim().toLowerCase()) + if (matches && matches.length > 0) { + const stillQueued = matches.find((image) => queue.some((entry) => entry.id === image.id)) + // Why: only take still-queued matches; never re-emit matches[0] after consume. + if (stillQueued) { + resolved = take(stillQueued) + } + } + } + + // Why: take() removes from queue; do not shift first or membership check fails. + if (!resolved && queue.length > 0) { + resolved = take(queue[0]) + } + if (resolved && attrs.id) { + resolvedByMediaId.set(attrs.id, resolved) + } + if (resolved && stats) { + stats.attachmentResolvedCount += 1 + } + return resolved + } +} diff --git a/src/main/jira/attachment-meta.ts b/src/main/jira/attachment-meta.ts new file mode 100644 index 00000000000..e7c67e7a2ec --- /dev/null +++ b/src/main/jira/attachment-meta.ts @@ -0,0 +1,56 @@ +// Why: image inlined as data URLs over IPC — keep per-image and selection caps modest. +export const MAX_IMAGE_BYTES = 2 * 1024 * 1024 +export const MAX_TOTAL_IMAGE_BYTES = 5 * 1024 * 1024 +export const MAX_IMAGES = 12 + +export type AttachmentMeta = { + id: string + filename: string + mimeType: string + size: number + contentUrl?: string +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? (value as Record) : {} +} + +function asString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +export function isImageMimeType(mimeType: string): boolean { + const normalized = mimeType.toLowerCase() + return ( + normalized.startsWith('image/') && !normalized.includes('svg') // Why: SVG can carry script; stick to raster screenshots. + ) +} + +export function parseImageAttachmentMetas(attachmentField: unknown): AttachmentMeta[] { + if (!Array.isArray(attachmentField)) { + return [] + } + const metas: AttachmentMeta[] = [] + for (const item of attachmentField) { + const record = asRecord(item) + const id = asString(record.id) || (typeof record.id === 'number' ? String(record.id) : '') + const filename = asString(record.filename) || `attachment-${id}` + const mimeType = asString(record.mimeType) + const size = typeof record.size === 'number' && Number.isFinite(record.size) ? record.size : 0 + if (!id || !isImageMimeType(mimeType)) { + continue + } + if (size > MAX_IMAGE_BYTES) { + continue + } + const contentUrl = asString(record.content) + metas.push({ + id, + filename, + mimeType, + size, + ...(contentUrl ? { contentUrl } : {}) + }) + } + return metas +} diff --git a/src/main/jira/client.test.ts b/src/main/jira/client.test.ts index 0c0ee11511c..e7c3a696338 100644 --- a/src/main/jira/client.test.ts +++ b/src/main/jira/client.test.ts @@ -201,6 +201,40 @@ describe('Jira client credential storage', () => { expect(userAgent).not.toMatch(/Mozilla|Chrome|Safari|AppleWebKit/i) }) + it('downloads same-origin attachment URLs without forwarding auth cross-origin', async () => { + const jira = await loadClientModule({ encryptionAvailable: true }) + const client = { + site: { + id: 'site-alpha', + siteUrl: 'https://example.atlassian.net', + email: 'ada@example.com', + displayName: 'Ada', + accountId: 'account-alpha' + }, + authorization: 'Basic token-alpha' + } + netFetchMock.mockResolvedValueOnce( + new Response(Uint8Array.from([1, 2, 3]), { + status: 200, + headers: { 'Content-Type': 'image/png' } + }) + ) + + await expect( + jira.jiraRequestBinary( + client, + 'https://example.atlassian.net/rest/api/3/attachment/content/1?redirect=false' + ) + ).resolves.toMatchObject({ contentType: 'image/png' }) + const headers = netFetchMock.mock.calls[0]?.[1]?.headers as Headers + expect(headers.get('Authorization')).toBe('Basic token-alpha') + + await expect( + jira.jiraRequestBinary(client, 'https://files.example.com/attachment.png') + ).rejects.toThrow('configured site origin') + expect(netFetchMock).toHaveBeenCalledTimes(1) + }) + it('does not pass encrypted safeStorage bytes to Jira when encryption is unavailable', async () => { const siteId = 'site-alpha' const tokenPath = tokenPathForSite(siteId) diff --git a/src/main/jira/client.ts b/src/main/jira/client.ts index 4c182ee2ad5..336426ba966 100644 --- a/src/main/jira/client.ts +++ b/src/main/jira/client.ts @@ -21,6 +21,7 @@ import type { JiraSiteSelection, JiraViewer } from '../../shared/types' +import { clearAttachmentImagesForSite } from './attachment-image-cache' // Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser // User-Agent, failing them with "XSRF check failed" even under API-token auth. @@ -457,6 +458,36 @@ export async function jiraRequest( return (await response.json()) as T } +export async function jiraRequestBinary( + client: JiraClientForSite, + pathOrUrl: string +): Promise<{ data: ArrayBuffer; contentType: string }> { + const siteUrl = new URL(client.site.siteUrl) + const requestUrl = /^https?:\/\//i.test(pathOrUrl) + ? new URL(pathOrUrl) + : new URL(`${client.site.siteUrl}${pathOrUrl}`) + if (requestUrl.origin !== siteUrl.origin) { + // Why: attachment metadata is provider-controlled; never forward Jira + // credentials if a malformed response points at another origin. + throw new JiraApiError('Jira attachment URL must use the configured site origin.', null) + } + const headers = new Headers() + // Why: attachment content is binary; forcing JSON Accept/Content-Type can + // break downloads and confuses some Atlassian edge responses. + headers.set('Accept', '*/*') + headers.set('User-Agent', JIRA_API_USER_AGENT) + headers.set('Authorization', client.authorization) + const response = await jiraFetch(requestUrl.toString(), { headers }) + if (!response.ok) { + throw new JiraApiError(await readJiraError(response), response.status) + } + const contentType = response.headers.get('content-type') || 'application/octet-stream' + return { + data: await response.arrayBuffer(), + contentType + } +} + export function getClients(selection?: JiraSiteSelection | null): JiraClientForSite[] { const file = getSiteFile() const selected = selection ?? file.selectedSiteId ?? file.activeSiteId @@ -576,6 +607,9 @@ export function disconnect(siteId?: string): void { for (const id of ids) { deleteToken(id) } + // Why: drop cached attachment data URLs for disconnected sites so main does + // not retain multi-MB strings after logout. + clearAttachmentImagesForSite(siteId) writeSiteFile({ version: 1, activeSiteId: file.activeSiteId, @@ -625,6 +659,8 @@ export async function testConnection( export function clearToken(siteId: string): void { deleteToken(siteId) + // Why: auth failure removes the site; drop cached attachment data URLs too. + clearAttachmentImagesForSite(siteId) const file = getSiteFile() writeSiteFile({ ...file, sites: file.sites.filter((site) => site.id !== siteId) }) } diff --git a/src/main/jira/issues.test.ts b/src/main/jira/issues.test.ts index 4b20b0c4a2f..134b7543dab 100644 --- a/src/main/jira/issues.test.ts +++ b/src/main/jira/issues.test.ts @@ -2,22 +2,41 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { JiraClientForSite } from './client' import { credentialDecryptionMessage } from '../../shared/integration-credential-errors' -const { clearTokenMock, getClientsMock, isAuthErrorMock, jiraRequestMock } = vi.hoisted(() => ({ +const { + clearTokenMock, + getClientsMock, + isAuthErrorMock, + jiraRequestMock, + jiraRequestBinaryMock, + acquireMock, + releaseMock +} = vi.hoisted(() => ({ clearTokenMock: vi.fn(), getClientsMock: vi.fn(), isAuthErrorMock: vi.fn(), - jiraRequestMock: vi.fn() + jiraRequestMock: vi.fn(), + jiraRequestBinaryMock: vi.fn(), + acquireMock: vi.fn().mockResolvedValue(undefined), + releaseMock: vi.fn() })) vi.mock('./client', () => ({ - acquire: vi.fn().mockResolvedValue(undefined), - release: vi.fn(), + acquire: (...args: unknown[]) => acquireMock(...args), + release: (...args: unknown[]) => releaseMock(...args), apiBasePath: (site: { authType?: string }) => site.authType === 'server' ? '/rest/api/2' : '/rest/api/3', clearToken: (...args: unknown[]) => clearTokenMock(...args), getClients: (...args: unknown[]) => getClientsMock(...args), isAuthError: (...args: unknown[]) => isAuthErrorMock(...args), - jiraRequest: (...args: unknown[]) => jiraRequestMock(...args) + jiraRequest: (...args: unknown[]) => jiraRequestMock(...args), + jiraRequestBinary: (...args: unknown[]) => jiraRequestBinaryMock(...args), + JiraApiError: class JiraApiError extends Error { + status: number | null + constructor(message: string, status: number | null = null) { + super(message) + this.status = status + } + } })) function makeEntry(id = 'site-1'): JiraClientForSite { @@ -48,10 +67,16 @@ function makeServerEntry(id = 'server-1'): JiraClientForSite { } describe('Jira issue operations', () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() isAuthErrorMock.mockReturnValue(false) getClientsMock.mockReturnValue([makeEntry()]) + acquireMock.mockResolvedValue(undefined) + releaseMock.mockImplementation(() => {}) + jiraRequestBinaryMock.mockReset() + jiraRequestMock.mockReset() + const { _resetAttachmentImageCache } = await import('./attachment-image-cache') + _resetAttachmentImageCache() }) it('surfaces Jira credential decrypt errors on active issue, metadata, and mutation paths', async () => { @@ -341,6 +366,73 @@ describe('Jira issue operations', () => { }) }) + it('embeds resolved attachment images into getIssue descriptions', async () => { + const pngBytes = Uint8Array.from([137, 80, 78, 71]) + jiraRequestBinaryMock.mockResolvedValue({ + data: pngBytes.buffer, + contentType: 'image/png' + }) + jiraRequestMock.mockResolvedValue({ + id: 'issue-9', + key: 'CAM-9', + fields: { + summary: 'UI with screenshot', + description: { + type: 'doc', + version: 1, + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'See image' }] }, + { + type: 'mediaSingle', + content: [ + { + type: 'media', + attrs: { id: 'media-uuid', type: 'file', alt: 'ui.png' } + } + ] + } + ] + }, + attachment: [ + { + id: '10001', + filename: 'ui.png', + mimeType: 'image/png', + size: 4 + } + ], + project: { id: '1', key: 'CAM', name: 'CAM' }, + issuetype: { id: '1', name: 'Story' }, + status: { + id: '1', + name: 'To Do', + statusCategory: { key: 'new', name: 'To Do' } + }, + labels: [], + created: '2026-06-18T00:00:00.000Z', + updated: '2026-06-18T00:00:00.000Z' + }, + renderedFields: { + description: + '

    See image

    ' + } + }) + + const { getIssue } = await import('./issues') + const issue = await getIssue('CAM-9', 'site-1') + + expect(jiraRequestMock).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('expand=renderedFields') + ) + expect(jiraRequestBinaryMock).toHaveBeenCalledWith( + expect.anything(), + 'https://example.atlassian.net/rest/api/3/attachment/content/10001?redirect=false' + ) + expect(issue?.description).toContain('See image') + expect(issue?.description).toContain('![ui.png](data:image/png;base64,') + }) + it('maps Jira ADF descriptions into Markdown blocks and lists', async () => { const { mapJiraIssue } = await import('./issues') @@ -474,6 +566,161 @@ describe('Jira issue operations', () => { updatedAt: undefined } ]) + expect(jiraRequestMock).toHaveBeenCalledTimes(1) + expect(String(jiraRequestMock.mock.calls[0]?.[1])).toContain('expand=renderedBody') + expect(jiraRequestBinaryMock).not.toHaveBeenCalled() + }) + + it('releases the Jira slot before downloading issue attachment binaries', async () => { + const order: string[] = [] + acquireMock.mockImplementation(async () => { + order.push('acquire') + }) + releaseMock.mockImplementation(() => { + order.push('release') + }) + jiraRequestMock.mockImplementation(async () => { + order.push('json') + return { + id: 'issue-9', + key: 'CAM-9', + fields: { + summary: 'UI with screenshot', + description: { + type: 'doc', + version: 1, + content: [ + { + type: 'mediaSingle', + content: [ + { type: 'media', attrs: { id: 'media-uuid', type: 'file', alt: 'ui.png' } } + ] + } + ] + }, + attachment: [{ id: '10001', filename: 'ui.png', mimeType: 'image/png', size: 4 }], + project: { id: '1', key: 'CAM', name: 'CAM' }, + issuetype: { id: '1', name: 'Bug' }, + status: { id: '1', name: 'To Do', statusCategory: { key: 'new' } }, + labels: [], + created: '2026-05-01T00:00:00.000Z', + updated: '2026-05-01T00:00:00.000Z' + }, + renderedFields: { + description: + '' + } + } + }) + jiraRequestBinaryMock.mockImplementation(async () => { + order.push('binary') + return { data: Uint8Array.from([1]).buffer, contentType: 'image/png' } + }) + const { getIssue } = await import('./issues') + await getIssue('CAM-9', 'site-1') + expect(order.indexOf('release')).toBeLessThan(order.indexOf('binary')) + expect(order.indexOf('json')).toBeLessThan(order.indexOf('release')) + }) + + it('uses Server/DC api base path for comment attachment metadata lookup', async () => { + getClientsMock.mockReturnValue([makeServerEntry()]) + jiraRequestMock + .mockResolvedValueOnce({ + comments: [ + { + id: 'c1', + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'mediaSingle', + content: [{ type: 'media', attrs: { id: 'm1', type: 'file', alt: 'shot.png' } }] + } + ] + }, + renderedBody: '', + created: '2026-05-30T12:00:00.000Z', + author: { accountId: 'u1', displayName: 'Ada' } + } + ] + }) + .mockResolvedValueOnce({ + fields: { + attachment: [ + { + id: '9', + filename: 'shot.png', + mimeType: 'image/png', + size: 4, + content: 'https://jira.example.com/secure/attachment/9/shot.png' + } + ] + } + }) + jiraRequestBinaryMock.mockResolvedValue({ + data: Uint8Array.from([1]).buffer, + contentType: 'image/png' + }) + const { getIssueComments } = await import('./issues') + await getIssueComments('ALP-1', 'server-1') + const attachmentLookup = jiraRequestMock.mock.calls.find((call) => + String(call[1]).includes('fields=attachment') + ) + expect(String(attachmentLookup?.[1])).toContain('/rest/api/2/issue/') + expect(String(attachmentLookup?.[1])).not.toContain('/rest/api/3/issue/') + }) + + it('embeds only attachments referenced by rendered Jira comments', async () => { + jiraRequestMock + .mockResolvedValueOnce({ + comments: [ + { + id: 'comment-1', + body: { + type: 'doc', + version: 1, + content: [ + { + type: 'mediaSingle', + content: [ + { + type: 'media', + attrs: { id: 'media-uuid', type: 'file', alt: 'comment.png' } + } + ] + } + ] + }, + renderedBody: + '', + created: '2026-05-30T12:00:00.000Z', + author: { accountId: 'user-1', displayName: 'Ada' } + } + ] + }) + .mockResolvedValueOnce({ + fields: { + attachment: [ + { id: '20001', filename: 'unrelated.png', mimeType: 'image/png', size: 4 }, + { id: '20002', filename: 'comment.png', mimeType: 'image/png', size: 4 } + ] + } + }) + jiraRequestBinaryMock.mockResolvedValue({ + data: Uint8Array.from([137, 80, 78, 71]).buffer, + contentType: 'image/png' + }) + const { getIssueComments } = await import('./issues') + + const comments = await getIssueComments('ALP-1', 'site-1') + + expect(comments[0]?.body).toContain('![comment.png](data:image/png;base64,') + expect(jiraRequestBinaryMock).toHaveBeenCalledTimes(1) + expect(jiraRequestBinaryMock).toHaveBeenCalledWith( + expect.anything(), + 'https://example.atlassian.net/rest/api/3/attachment/content/20002?redirect=false' + ) }) describe('getProjectStatusOrder', () => { diff --git a/src/main/jira/issues.ts b/src/main/jira/issues.ts index 2ce24a87420..339e2276d2c 100644 --- a/src/main/jira/issues.ts +++ b/src/main/jira/issues.ts @@ -31,7 +31,23 @@ import { release, type JiraClientForSite } from './client' -import { adfToMarkdownText, textToAdf } from './adf-markdown' +import { + adfToMarkdownText, + collectAdfMediaAttrs, + textToAdf, + type AdfToMarkdownOptions, + type JiraAdfMediaAttrs +} from './adf-markdown' +import { + extractAttachmentContentIdsFromHtml, + selectPreferredAttachmentIds, + warnIfMediaResolutionIncomplete +} from './attachment-discovery' +import { + createMediaMarkdownResolver, + loadIssueImageAttachments, + type MediaResolutionStats +} from './attachment-images' const ISSUE_FIELDS = [ 'summary', @@ -47,6 +63,10 @@ const ISSUE_FIELDS = [ 'updated' ] +// Why: detail reads need attachment metadata so inline ADF media can be resolved +// to downloadable image content; list/search omit this for payload size. +const ISSUE_DETAIL_FIELDS = [...ISSUE_FIELDS, 'attachment'] + type JiraRecord = Record type JiraSearchResponse = { @@ -314,7 +334,11 @@ function toBodyText(site: JiraSite, text: string): unknown { return site.authType === 'server' ? text : textToAdf(text) } -export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { +export function mapJiraIssue( + site: JiraSite, + raw: JiraRecord, + adfOptions?: AdfToMarkdownOptions +): JiraIssue { const fields = asRecord(raw.fields) const key = asString(raw.key) return { @@ -323,7 +347,7 @@ export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { siteId: site.id, siteName: site.displayName, title: asString(fields.summary, key || 'Untitled issue'), - description: adfToMarkdownText(fields.description), + description: adfToMarkdownText(fields.description, adfOptions), url: issueUrl(site, key), project: mapProject(fields.project, site), issueType: mapIssueType(fields.issuetype), @@ -337,6 +361,97 @@ export function mapJiraIssue(site: JiraSite, raw: JiraRecord): JiraIssue { } } +type MediaRequest = { + attachmentField: unknown + preferredIds: string[] + needCount: number + fallbackRan: boolean + issueKey: string +} + +/** Pooled: HTML/ADF selection only — no binary downloads. */ +function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined { + const fields = asRecord(raw.fields) + const renderedFields = asRecord(raw.renderedFields) + const htmlIds = extractAttachmentContentIdsFromHtml( + asString(renderedFields.description) || undefined + ) + const mediaAttrs = collectAdfMediaAttrs(fields.description) + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField: fields.attachment, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField: fields.attachment, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: asString(raw.key) + } +} + +type PreparedMedia = { + options: AdfToMarkdownOptions + stats: MediaResolutionStats + request: MediaRequest +} + +/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */ +async function prepareMediaResolver( + client: JiraClientForSite, + request: MediaRequest +): Promise { + if (request.preferredIds.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: 0, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const images = await loadIssueImageAttachments( + client, + request.attachmentField, + request.preferredIds + ) + if (images.length === 0) { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: request.issueKey, + needCount: request.needCount, + preferredIdCount: request.preferredIds.length, + resolvedCount: 0, + fallbackRan: request.fallbackRan + }) + return undefined + } + const stats: MediaResolutionStats = { attachmentResolvedCount: 0 } + const resolveMedia = createMediaMarkdownResolver(images, request.preferredIds, stats) + return { + options: { resolveMedia }, + stats, + request + } +} + +function flushMediaResolutionWarn(client: JiraClientForSite, prepared: PreparedMedia): void { + warnIfMediaResolutionIncomplete({ + siteId: client.site.id, + issueKey: prepared.request.issueKey, + needCount: prepared.request.needCount, + preferredIdCount: prepared.request.preferredIds.length, + resolvedCount: prepared.stats.attachmentResolvedCount, + fallbackRan: prepared.request.fallbackRan + }) +} + function sortAndLimitIssues(issues: JiraIssue[], limit: number): JiraIssue[] { return issues .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()) @@ -437,15 +552,22 @@ export async function getIssue( ): Promise { const entries = getClients(siteId) for (const entry of entries) { - await acquire() + let mediaRequest: MediaRequest | undefined + let issue: JiraRecord | undefined + let held = false try { - const issue = await jiraRequest( + await acquire() + held = true + const params = new URLSearchParams({ + fields: ISSUE_DETAIL_FIELDS.join(','), + expand: 'renderedFields' + }) + issue = await jiraRequest( entry, - `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?fields=${encodeURIComponent( - ISSUE_FIELDS.join(',') - )}` + `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}` ) - return mapJiraIssue(entry.site, issue) + // Why: keep only JSON under the pool; binary downloads fan out after release. + mediaRequest = collectIssueMediaRequest(issue) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) @@ -455,8 +577,27 @@ export async function getIssue( } else { console.warn('[jira] getIssue failed:', error) } + continue } finally { - release() + if (held) { + held = false + release() + } + } + + try { + if (!issue) { + continue + } + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = mapJiraIssue(entry.site, issue, prepared?.options) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssue media load failed:', error) + return mapJiraIssue(entry.site, issue) } } return null @@ -597,16 +738,79 @@ export async function addIssueComment( } } -function mapComment(raw: JiraRecord): JiraComment { +function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraComment { return { id: asString(raw.id), - body: adfToMarkdownText(raw.body), + body: adfToMarkdownText(raw.body, adfOptions), createdAt: asString(raw.created, new Date().toISOString()), updatedAt: asString(raw.updated) || undefined, user: mapUser(raw.author) } } +/** + * Pooled comment media collect: attachment metadata JSON stays under the semaphore. + * Residual: Server/DC comment bodies are wiki markup, not ADF — this only fixes + * the lookup path; wiki `!filename!` is not rendered as media. + */ +async function collectCommentMediaRequest( + client: JiraClientForSite, + key: string, + comments: JiraRecord[] +): Promise { + const htmlIds: string[] = [] + const seen = new Set() + const mediaAttrs: JiraAdfMediaAttrs[] = [] + for (const comment of comments) { + for (const id of extractAttachmentContentIdsFromHtml(asString(comment.renderedBody))) { + if (!seen.has(id)) { + seen.add(id) + htmlIds.push(id) + } + } + mediaAttrs.push(...collectAdfMediaAttrs(comment.body)) + } + + const needingCount = mediaAttrs.filter( + (attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)) + ).length + // Why: selectPreferredAttachmentIds yields nothing without attachment-needing media, so + // HTML ids alone can never produce a download — skip the extra metadata request entirely. + if (needingCount === 0) { + return undefined + } + + // Why: comment media usually references issue-level attachments; pull them once + // for the whole thread. Use apiBasePath so Server/DC does not 404 on /rest/api/3. + let attachmentField: unknown + try { + const issue = await jiraRequest( + client, + `${apiBasePath(client.site)}/issue/${encodeURIComponent(key)}?fields=attachment` + ) + attachmentField = asRecord(issue.fields).attachment + } catch (error) { + console.warn('[jira] comment attachment lookup failed:', error) + return undefined + } + + const selection = selectPreferredAttachmentIds({ + renderedHtmlIds: htmlIds, + attachmentField, + mediaAttrs + }) + if (selection.needCount === 0 && selection.preferredIds.length === 0) { + return undefined + } + return { + attachmentField, + preferredIds: selection.preferredIds, + needCount: selection.needCount, + fallbackRan: selection.fallbackRan, + issueKey: key + } +} + export async function getIssueComments( key: string, siteId?: string | null @@ -615,17 +819,23 @@ export async function getIssueComments( if (!entry) { return [] } - await acquire() + + let comments: JiraRecord[] = [] + let mediaRequest: MediaRequest | undefined + let held = false try { - const comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { + await acquire() + held = true + comments = await fetchPagedRecords(entry, 'comments', (startAt, maxResults) => { const params = new URLSearchParams({ maxResults: String(maxResults), orderBy: 'created', - startAt: String(startAt) + startAt: String(startAt), + expand: 'renderedBody' }) return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}` }) - return comments.map(mapComment) + mediaRequest = await collectCommentMediaRequest(entry, key, comments) } catch (error) { if (isAuthError(error)) { clearToken(entry.site.id) @@ -634,7 +844,22 @@ export async function getIssueComments( console.warn('[jira] getIssueComments failed:', error) return [] } finally { - release() + if (held) { + held = false + release() + } + } + + try { + const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined + const mapped = comments.map((comment) => mapComment(comment, prepared?.options)) + if (prepared) { + flushMediaResolutionWarn(entry, prepared) + } + return mapped + } catch (error) { + console.warn('[jira] getIssueComments media load failed:', error) + return comments.map((comment) => mapComment(comment)) } } diff --git a/src/main/keybindings/keybinding-file.test.ts b/src/main/keybindings/keybinding-file.test.ts index 4fd295d4c50..a722e2c5ed7 100644 --- a/src/main/keybindings/keybinding-file.test.ts +++ b/src/main/keybindings/keybinding-file.test.ts @@ -92,6 +92,32 @@ describe('keybinding-file', () => { }) }) + it('preserves valid plugin overrides while rejecting malformed plugin action IDs', () => { + writeFileSync( + filePath, + JSON.stringify({ + keybindings: { + 'plugin:orca-samples.tasks/open': 'Mod+Shift+T', + 'plugin:tasks/open': 'Mod+Alt+T' + } + }), + 'utf8' + ) + + const snapshot = readKeybindingFile(filePath, 'linux') + expect(snapshot.overrides).toEqual({ + 'plugin:orca-samples.tasks/open': ['Mod+Shift+T'] + }) + expect(snapshot.diagnostics).toMatchObject([ + { severity: 'warning', actionId: 'plugin:tasks/open' } + ]) + + writeKeybindingOverride(filePath, 'linux', 'plugin:orca-samples.tasks/open', []) + expect(readKeybindingFile(filePath, 'linux').overrides).toEqual({ + 'plugin:orca-samples.tasks/open': [] + }) + }) + it('ignores invalid, unknown, and conflicting manual edits', () => { writeFileSync( filePath, diff --git a/src/main/linear/issue-relation-write.ts b/src/main/linear/issue-relation-write.ts index af3f95e9b63..6f9ce71c634 100644 --- a/src/main/linear/issue-relation-write.ts +++ b/src/main/linear/issue-relation-write.ts @@ -1,4 +1,5 @@ -import { LinearClient } from '@linear/sdk' +import type { LinearClient } from '@linear/sdk' +import { loadLinearSdk } from './linear-sdk' import type { LinearIssueRelationship, LinearIssueRelationWriteResult @@ -34,7 +35,7 @@ export async function writeIssueRelation(params: { await acquire() try { const client = params.signal - ? new LinearClient({ apiKey: entry.apiKey, signal: params.signal }) + ? new (loadLinearSdk().LinearClient)({ apiKey: entry.apiKey, signal: params.signal }) : entry.client const existing = await findExistingRelation(client, params) if (params.operation === 'add' && existing) { diff --git a/src/main/local-builds/local-build-candidate.test.ts b/src/main/local-builds/local-build-candidate.test.ts new file mode 100644 index 00000000000..2807164ed9c --- /dev/null +++ b/src/main/local-builds/local-build-candidate.test.ts @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto' +import { execFile } from 'node:child_process' +import { mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import { stringify } from 'yaml' +import type { LocalBuildCompatibility } from '../../shared/local-build-compatibility' +import { loadLocalBuildCandidate } from './local-build-candidate' +import { startLocalBuildFeed } from './local-build-feed-server' + +const tempDirectories: string[] = [] +const execFileAsync = promisify(execFile) + +function compatibility(): LocalBuildCompatibility { + return { + formatVersion: 1, + appId: 'com.stablyai.orca', + buildId: '1.2.3-local.1-abc-arm64', + version: '1.2.3-local.1', + commit: 'abc', + stateSchemaVersion: 1, + readableStateSchemaVersions: [1], + daemonProtocolVersion: 28, + attachableDaemonProtocolVersions: [28], + platform: 'darwin', + architecture: 'arm64' + } +} + +async function fixture(options: { sha512?: string; url?: string } = {}) { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-build-')) + tempDirectories.push(directory) + const artifactName = 'orca-macos-arm64.zip' + const artifactPath = join(directory, artifactName) + const content = Buffer.from('signed-zip-placeholder') + await writeFile(artifactPath, content) + const manifestPath = join(directory, 'latest-mac.yml') + await writeFile( + manifestPath, + stringify({ + version: compatibility().version, + files: [ + { + url: options.url ?? artifactName, + sha512: options.sha512 ?? createHash('sha512').update(content).digest('base64'), + size: content.length + }, + { + url: 'orca-macos-arm64.dmg', + sha512: Buffer.alloc(64).toString('base64'), + size: 1 + } + ] + }) + ) + return { artifactPath, directory, manifestPath } +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })) + ) +}) + +describe('loadLocalBuildCandidate', () => { + it('returns a sanitized, architecture-specific feed after hash validation', async () => { + const { manifestPath } = await fixture() + const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + + expect(candidate.version).toBe('1.2.3-local.1') + expect([...candidate.artifacts.keys()]).toEqual(['orca-macos-arm64.zip']) + expect(candidate.manifestContent).toContain('orca-macos-arm64.zip') + await candidate.close() + }) + + it('rejects mismatched hashes and traversal paths', async () => { + const badHash = await fixture({ sha512: Buffer.alloc(64).toString('base64') }) + await expect( + loadLocalBuildCandidate(badHash.manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('SHA-512 verification failed') + + const traversal = await fixture({ url: '../orca-macos-arm64.zip' }) + await expect( + loadLocalBuildCandidate(traversal.manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('invalid file entry') + }) + + it('rejects symlinked artifacts', async () => { + const { artifactPath, directory, manifestPath } = await fixture() + const realArtifact = join(directory, 'real.zip') + await writeFile(realArtifact, 'signed-zip-placeholder') + await rm(artifactPath) + await symlink(realArtifact, artifactPath) + + await expect( + loadLocalBuildCandidate(manifestPath, 'arm64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('regular files, not links') + }) + + it('serves the same artifact descriptor that passed validation', async () => { + const { artifactPath, directory, manifestPath } = await fixture() + const movedArtifactPath = join(directory, 'validated.zip') + const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64', { + readCompatibility: async () => { + await rename(artifactPath, movedArtifactPath) + await writeFile(artifactPath, 'replacement') + return compatibility() + } + }) + const feed = await startLocalBuildFeed(candidate) + try { + await expect( + fetch(`${feed.url}orca-macos-arm64.zip`).then((response) => response.text()) + ).resolves.toBe('signed-zip-placeholder') + } finally { + await feed.close() + } + }) + + it.runIf(process.platform === 'darwin')( + 'reads signed compatibility metadata through the held artifact descriptor', + async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-build-zip-')) + tempDirectories.push(directory) + const zipRoot = join(directory, 'zip-root') + const resources = join(zipRoot, 'Orca.app', 'Contents', 'Resources') + await mkdir(resources, { recursive: true }) + await writeFile(join(resources, 'orca-local-build.json'), JSON.stringify(compatibility())) + const artifactName = 'orca-macos-arm64.zip' + const artifactPath = join(directory, artifactName) + await execFileAsync('/usr/bin/zip', ['-qry', artifactPath, 'Orca.app'], { cwd: zipRoot }) + const artifact = await readFile(artifactPath) + const manifestPath = join(directory, 'latest-mac.yml') + await writeFile( + manifestPath, + stringify({ + version: compatibility().version, + files: [ + { + url: artifactName, + sha512: createHash('sha512').update(artifact).digest('base64'), + size: artifact.length + } + ] + }) + ) + + const candidate = await loadLocalBuildCandidate(manifestPath, 'arm64') + expect(candidate.compatibility).toEqual(compatibility()) + await candidate.close() + } + ) + + it('requires exactly one ZIP for the running architecture', async () => { + const { manifestPath } = await fixture() + await expect( + loadLocalBuildCandidate(manifestPath, 'x64', { + readCompatibility: async () => compatibility() + }) + ).rejects.toThrow('exactly one x64 Orca ZIP') + }) +}) diff --git a/src/main/local-builds/local-build-candidate.ts b/src/main/local-builds/local-build-candidate.ts new file mode 100644 index 00000000000..772a17bb77e --- /dev/null +++ b/src/main/local-builds/local-build-candidate.ts @@ -0,0 +1,279 @@ +import { createHash } from 'node:crypto' +import { spawn } from 'node:child_process' +import { constants } from 'node:fs' +import { lstat, open, realpath, type FileHandle } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { parse, stringify } from 'yaml' +import { + LOCAL_BUILD_COMPATIBILITY_FILENAME, + ORCA_APP_ID, + parseLocalBuildCompatibility, + type LocalBuildCompatibility +} from '../../shared/local-build-compatibility' +import { isValidAppVersion } from '../../shared/app-version' + +const MAX_MANIFEST_BYTES = 256 * 1024 +const MAX_COMPATIBILITY_BYTES = 64 * 1024 +const MAX_UPDATE_FILES = 8 +const MAX_ZIP_BYTES = 8 * 1024 * 1024 * 1024 +const SAFE_ARTIFACT_NAME = /^[A-Za-z0-9][A-Za-z0-9._ ()+-]*\.zip$/ + +type ManifestFile = { + url: string + sha512: string + size?: number +} + +export type LocalBuildCandidate = { + version: string + compatibility: LocalBuildCompatibility + manifestContent: string + artifacts: Map + close: () => Promise +} + +type LocalBuildCandidateLoaderOptions = { + readCompatibility?: (zipFile: FileHandle) => Promise +} + +function parseManifestFile(value: unknown): ManifestFile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The local update manifest contains an invalid file entry.') + } + const record = value as Record + if ( + typeof record.url !== 'string' || + !SAFE_ARTIFACT_NAME.test(record.url) || + basename(record.url) !== record.url || + typeof record.sha512 !== 'string' || + !/^[A-Za-z0-9+/]{86}==$/.test(record.sha512) || + (record.size !== undefined && (!Number.isSafeInteger(record.size) || Number(record.size) <= 0)) + ) { + throw new Error('The local update manifest contains an invalid file entry.') + } + return { + url: record.url, + sha512: record.sha512, + ...(record.size === undefined ? {} : { size: Number(record.size) }) + } +} + +function isZipManifestFile(value: unknown): boolean { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const url = (value as Record).url + return typeof url === 'string' && url.toLowerCase().endsWith('.zip') +} + +function parseManifest(value: unknown): { version: string; files: ManifestFile[] } { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('The selected file is not a valid macOS update manifest.') + } + const record = value as Record + if ( + typeof record.version !== 'string' || + record.version.length === 0 || + record.version.length > 100 || + !isValidAppVersion(record.version) || + !Array.isArray(record.files) || + record.files.length === 0 || + record.files.length > MAX_UPDATE_FILES + ) { + throw new Error('The selected file is not a valid macOS update manifest.') + } + const zipFiles = record.files.filter(isZipManifestFile) + if (zipFiles.length === 0) { + throw new Error('The selected macOS update manifest does not contain a ZIP.') + } + return { version: record.version, files: zipFiles.map(parseManifestFile) } +} + +async function hashFile(file: FileHandle): Promise { + const hash = createHash('sha512') + await new Promise((resolve, reject) => { + const stream = file.createReadStream({ autoClose: false, start: 0 }) + stream.on('data', (chunk) => hash.update(chunk)) + stream.on('error', reject) + stream.on('end', resolve) + }) + return hash.digest('base64') +} + +async function assertContainedRegularFile(rootPath: string, filePath: string): Promise { + const fileInfo = await lstat(filePath) + if (!fileInfo.isFile() || fileInfo.isSymbolicLink()) { + throw new Error('Local update artifacts must be regular files, not links.') + } + const [resolvedRoot, resolvedFile] = await Promise.all([realpath(rootPath), realpath(filePath)]) + if (dirname(resolvedFile) !== resolvedRoot) { + throw new Error('Local update artifacts must stay beside latest-mac.yml.') + } +} + +function extractCompatibility(zipFile: FileHandle): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + '/usr/bin/unzip', + ['-p', '/dev/fd/3', `Orca.app/Contents/Resources/${LOCAL_BUILD_COMPATIBILITY_FILENAME}`], + { stdio: ['ignore', 'pipe', 'ignore', zipFile.fd] } + ) + const chunks: Buffer[] = [] + let outputBytes = 0 + let outputError: Error | null = null + const stdout = child.stdout + if (!stdout) { + child.kill() + reject(new Error('Could not read compatibility metadata.')) + return + } + stdout.on('data', (chunk: Buffer) => { + outputBytes += chunk.length + if (outputBytes > MAX_COMPATIBILITY_BYTES) { + outputError = new Error('Compatibility metadata is too large.') + child.kill() + return + } + chunks.push(chunk) + }) + child.on('error', reject) + child.on('close', (code) => { + if (outputError) { + reject(outputError) + } else if (code !== 0) { + reject(new Error(`unzip exited with status ${code ?? 'unknown'}.`)) + } else { + resolve(Buffer.concat(chunks).toString('utf8')) + } + }) + }) +} + +async function readCompatibility(zipFile: FileHandle): Promise { + let stdout: string + try { + stdout = await extractCompatibility(zipFile) + } catch (error) { + console.warn('[local-build] Could not read compatibility metadata:', error) + throw new Error( + 'This build predates local switching or is missing its signed compatibility metadata.' + ) + } + try { + return parseLocalBuildCompatibility(JSON.parse(stdout)) + } catch (error) { + if (error instanceof SyntaxError) { + throw new Error('The selected build has malformed compatibility metadata.') + } + throw error + } +} + +async function validateArtifact( + rootPath: string, + manifestFile: ManifestFile, + manifestVersion: string, + compatibilityReader: (zipFile: FileHandle) => Promise +): Promise<{ compatibility: LocalBuildCompatibility; file: FileHandle; size: number }> { + const filePath = join(rootPath, manifestFile.url) + await assertContainedRegularFile(rootPath, filePath) + const file = await open( + filePath, + constants.O_RDONLY | (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0) + ) + try { + const fileStats = await file.stat() + if (!fileStats.isFile() || fileStats.size <= 0 || fileStats.size > MAX_ZIP_BYTES) { + throw new Error('The selected local build ZIP has an invalid size.') + } + if (manifestFile.size !== undefined && fileStats.size !== manifestFile.size) { + throw new Error(`Size verification failed for ${manifestFile.url}.`) + } + if ((await hashFile(file)) !== manifestFile.sha512) { + throw new Error(`SHA-512 verification failed for ${manifestFile.url}.`) + } + const compatibility = await compatibilityReader(file) + if (compatibility.appId !== ORCA_APP_ID || compatibility.version !== manifestVersion) { + throw new Error('The selected ZIP does not match its update manifest.') + } + return { compatibility, file, size: fileStats.size } + } catch (error) { + await file.close() + throw error + } +} + +export async function loadLocalBuildCandidate( + manifestPath: string, + architecture: NodeJS.Architecture, + options: LocalBuildCandidateLoaderOptions = {} +): Promise { + if (basename(manifestPath) !== 'latest-mac.yml') { + throw new Error('Select the latest-mac.yml generated by pn build:mac.') + } + await assertContainedRegularFile(dirname(manifestPath), manifestPath) + const manifestFile = await open( + manifestPath, + constants.O_RDONLY | (typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0) + ) + let manifestText: string + try { + const manifestStats = await manifestFile.stat() + if ( + !manifestStats.isFile() || + manifestStats.size <= 0 || + manifestStats.size > MAX_MANIFEST_BYTES + ) { + throw new Error('The selected update manifest is too large.') + } + manifestText = await manifestFile.readFile('utf8') + } finally { + await manifestFile.close() + } + const manifest = parseManifest(parse(manifestText, { maxAliasCount: 0 })) + const rootPath = dirname(manifestPath) + const compatibilityReader = options.readCompatibility ?? readCompatibility + const validationResults = await Promise.allSettled( + manifest.files.map((file) => + validateArtifact(rootPath, file, manifest.version, compatibilityReader) + ) + ) + const validated = validationResults + .filter((result) => result.status === 'fulfilled') + .map((result) => result.value) + const failed = validationResults.find((result) => result.status === 'rejected') + if (failed?.status === 'rejected') { + await Promise.all(validated.map((entry) => entry.file.close())) + throw failed.reason + } + const matching = validated + .map((entry, index) => ({ ...entry, manifestFile: manifest.files[index] })) + .filter((entry) => entry.compatibility.architecture === architecture) + if (matching.length !== 1) { + await Promise.all(validated.map((entry) => entry.file.close())) + throw new Error(`The manifest must contain exactly one ${architecture} Orca ZIP.`) + } + const target = matching[0] + await Promise.all( + validated.filter((entry) => entry.file !== target.file).map((entry) => entry.file.close()) + ) + const sanitizedFile = { + url: target.manifestFile.url, + sha512: target.manifestFile.sha512, + ...(target.manifestFile.size === undefined ? {} : { size: target.manifestFile.size }) + } + return { + version: manifest.version, + compatibility: target.compatibility, + manifestContent: stringify({ + version: manifest.version, + files: [sanitizedFile], + path: sanitizedFile.url, + sha512: sanitizedFile.sha512 + }), + artifacts: new Map([[target.manifestFile.url, { file: target.file, size: target.size }]]), + close: async () => { + await target.file.close() + } + } +} diff --git a/src/main/local-builds/local-build-compatibility-contract.test.ts b/src/main/local-builds/local-build-compatibility-contract.test.ts new file mode 100644 index 00000000000..dc2e2af54fd --- /dev/null +++ b/src/main/local-builds/local-build-compatibility-contract.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { SCHEMA_VERSION } from '../../shared/constants' +import compatibilityContract from '../../shared/local-build-compatibility-contract.json' +import { LOCAL_BUILD_COMPATIBILITY_CONTRACT } from '../../shared/local-build-compatibility-contract' +import { + PREVIOUS_DAEMON_PROTOCOL_VERSIONS, + PROTOCOL_VERSION +} from '../daemon/daemon-protocol-version' + +describe('packaged local build compatibility contract', () => { + it('stays aligned with runtime state and daemon constants', () => { + expect(LOCAL_BUILD_COMPATIBILITY_CONTRACT).toEqual(compatibilityContract) + expect(compatibilityContract).toMatchObject({ + appId: 'com.stablyai.orca', + stateSchemaVersion: SCHEMA_VERSION, + readableStateSchemaVersions: [SCHEMA_VERSION], + daemonProtocolVersion: PROTOCOL_VERSION, + attachableDaemonProtocolVersions: [...PREVIOUS_DAEMON_PROTOCOL_VERSIONS, PROTOCOL_VERSION] + }) + }) +}) diff --git a/src/main/local-builds/local-build-compatibility.ts b/src/main/local-builds/local-build-compatibility.ts new file mode 100644 index 00000000000..015377f9d5d --- /dev/null +++ b/src/main/local-builds/local-build-compatibility.ts @@ -0,0 +1,71 @@ +import { SCHEMA_VERSION } from '../../shared/constants' +import { + getLocalBuildCompatibilityError, + type LocalBuildCompatibility +} from '../../shared/local-build-compatibility' +import type { DaemonPtyAdapter } from '../daemon/daemon-pty-adapter' +import { DaemonPtyRouter } from '../daemon/daemon-pty-router' +import { DegradedDaemonPtyProvider } from '../daemon/degraded-daemon-pty-provider' +import { getDaemonProvider } from '../daemon/daemon-init' +import { getLocalPtyProvider } from '../ipc/pty' +import { LocalPtyProvider } from '../providers/local-pty-provider' + +export type LocalBuildCompatibilityResult = { + liveTerminalCount: number + liveDaemonProtocols: number[] +} + +async function getLiveDaemonProtocols(): Promise<{ + count: number + protocols: number[] +}> { + const provider = getDaemonProvider() + if (!provider) { + const localProvider = getLocalPtyProvider() + if (!(localProvider instanceof LocalPtyProvider)) { + throw new Error('Could not verify terminal preservation. Restart Orca and try again.') + } + const localProcesses = await localProvider.listProcesses() + if (localProcesses.length > 0) { + throw new Error( + 'Local build switching is blocked while non-persistent fallback terminals are running.' + ) + } + throw new Error('The terminal service is still starting. Try again in a moment.') + } + if (provider instanceof DegradedDaemonPtyProvider) { + throw new Error( + 'Local build switching is blocked while the terminal service is in fallback mode. Restart Orca first.' + ) + } + const adapters = + provider instanceof DaemonPtyRouter ? provider.getAllAdapters() : [provider as DaemonPtyAdapter] + const sessions = await Promise.all( + adapters.map(async (adapter) => ({ + protocol: adapter.protocolVersion, + count: (await adapter.listSessions()).length + })) + ) + return { + count: sessions.reduce((sum, entry) => sum + entry.count, 0), + protocols: sessions.filter((entry) => entry.count > 0).map((entry) => entry.protocol) + } +} + +export async function assertLocalBuildCompatibility( + target: LocalBuildCompatibility +): Promise { + const stateCompatibilityError = getLocalBuildCompatibilityError(target, SCHEMA_VERSION, []) + if (stateCompatibilityError) { + throw new Error(stateCompatibilityError) + } + const live = await getLiveDaemonProtocols() + const compatibilityError = getLocalBuildCompatibilityError(target, SCHEMA_VERSION, live.protocols) + if (compatibilityError) { + throw new Error(compatibilityError) + } + return { + liveTerminalCount: live.count, + liveDaemonProtocols: [...new Set(live.protocols)].sort((left, right) => left - right) + } +} diff --git a/src/main/local-builds/local-build-feed-server.test.ts b/src/main/local-builds/local-build-feed-server.test.ts new file mode 100644 index 00000000000..6c020c10fef --- /dev/null +++ b/src/main/local-builds/local-build-feed-server.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import type { LocalBuildCandidate } from './local-build-candidate' +import { startLocalBuildFeed } from './local-build-feed-server' + +describe('startLocalBuildFeed', () => { + it('serves only tokenized manifest and validated artifact routes', async () => { + const directory = await mkdtemp(join(tmpdir(), 'orca-local-feed-')) + const artifactPath = join(directory, 'orca-macos-arm64.zip') + await writeFile(artifactPath, 'zip') + const artifactFile = await open(artifactPath, 'r') + const candidate = { + version: '1.2.3-local.1', + manifestContent: 'version: 1.2.3-local.1\n', + artifacts: new Map([['orca-macos-arm64.zip', { file: artifactFile, size: 3 }]]), + close: () => artifactFile.close() + } as LocalBuildCandidate + const feed = await startLocalBuildFeed(candidate) + try { + await expect( + fetch(`${feed.url}latest-mac.yml`).then((response) => response.text()) + ).resolves.toContain('1.2.3-local.1') + await expect( + fetch(`${feed.url}orca-macos-arm64.zip`).then((response) => response.text()) + ).resolves.toBe('zip') + const baseUrl = new URL(feed.url) + await expect( + fetch(`${baseUrl.origin}/latest-mac.yml`).then((response) => response.status) + ).resolves.toBe(404) + } finally { + await feed.close() + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/local-builds/local-build-feed-server.ts b/src/main/local-builds/local-build-feed-server.ts new file mode 100644 index 00000000000..443cf17bfc2 --- /dev/null +++ b/src/main/local-builds/local-build-feed-server.ts @@ -0,0 +1,92 @@ +import { randomBytes } from 'node:crypto' +import { createServer, type Server } from 'node:http' +import type { LocalBuildCandidate } from './local-build-candidate' + +export type LocalBuildFeed = { + url: string + close: () => Promise +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()) + }) +} + +export async function startLocalBuildFeed(candidate: LocalBuildCandidate): Promise { + const token = randomBytes(24).toString('hex') + const prefix = `/${token}/` + const server = createServer((request, response) => { + if (request.method !== 'GET' || !request.url) { + response.writeHead(404).end() + return + } + let pathname: string + try { + pathname = decodeURIComponent(new URL(request.url, 'http://127.0.0.1').pathname) + } catch { + response.writeHead(400).end() + return + } + if (!pathname.startsWith(prefix)) { + response.writeHead(404).end() + return + } + const filename = pathname.slice(prefix.length) + if (filename === 'latest-mac.yml') { + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/yaml; charset=utf-8' + }) + response.end(candidate.manifestContent) + return + } + const artifact = candidate.artifacts.get(filename) + if (!artifact) { + response.writeHead(404).end() + return + } + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/zip' + }) + const stream = artifact.file.createReadStream({ + autoClose: false, + start: 0, + end: artifact.size - 1 + }) + stream.on('error', () => response.destroy()) + response.on('error', () => stream.destroy()) + response.on('close', () => stream.destroy()) + stream.pipe(response) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + server.on('error', (error) => console.warn('[updater] Local build feed error:', error)) + resolve() + }) + }).catch(async (error) => { + await candidate.close() + throw error + }) + const address = server.address() + if (!address || typeof address === 'string') { + await closeServer(server) + await candidate.close() + throw new Error('Could not start the local update feed.') + } + let closed = false + return { + url: `http://127.0.0.1:${address.port}${prefix}`, + close: async () => { + if (closed) { + return + } + closed = true + await closeServer(server) + await candidate.close() + } + } +} diff --git a/src/main/local-builds/local-build-switch.ts b/src/main/local-builds/local-build-switch.ts new file mode 100644 index 00000000000..2f1982c726e --- /dev/null +++ b/src/main/local-builds/local-build-switch.ts @@ -0,0 +1,59 @@ +import { app, dialog, type BrowserWindow } from 'electron' +import { SCHEMA_VERSION } from '../../shared/constants' +import { compareAppVersions } from '../../shared/app-version' +import { assertLocalBuildCompatibility } from './local-build-compatibility' +import { loadLocalBuildCandidate, type LocalBuildCandidate } from './local-build-candidate' + +export async function chooseLocalBuild( + window: BrowserWindow | null +): Promise { + const openDialogOptions: Electron.OpenDialogOptions = { + title: 'Choose a Local Orca Build', + buttonLabel: 'Choose Build', + properties: ['openFile'], + filters: [{ name: 'Orca update manifest', extensions: ['yml'] }] + } + const selection = await (window + ? dialog.showOpenDialog(window, openDialogOptions) + : dialog.showOpenDialog(openDialogOptions)) + const manifestPath = selection.filePaths[0] + if (selection.canceled || !manifestPath) { + return null + } + const candidate = await loadLocalBuildCandidate(manifestPath, process.arch) + try { + if (compareAppVersions(candidate.version, app.getVersion()) === 0) { + throw new Error( + 'This build has the same version as the running app. Run pn build:mac again to create a uniquely versioned build.' + ) + } + const compatibility = await assertLocalBuildCompatibility(candidate.compatibility) + const terminalSummary = + compatibility.liveTerminalCount === 0 + ? 'No live terminals need to reconnect.' + : `${compatibility.liveTerminalCount} live terminal${ + compatibility.liveTerminalCount === 1 ? '' : 's' + } will reconnect after restart.` + const messageBoxOptions: Electron.MessageBoxOptions = { + type: 'question', + title: 'Use Local Orca Build?', + message: `${app.getVersion()} → ${candidate.version}`, + detail: `${terminalSummary}\nWorkspace cards and settings are compatible with state schema ${SCHEMA_VERSION}.\n\nThe build must have the same valid code signature as Orca or installation will stop.`, + buttons: ['Use Local Build', 'Cancel'], + defaultId: 0, + cancelId: 1, + noLink: true + } + const confirmation = await (window + ? dialog.showMessageBox(window, messageBoxOptions) + : dialog.showMessageBox(messageBoxOptions)) + if (confirmation.response === 0) { + return candidate + } + } catch (error) { + await candidate.close() + throw error + } + await candidate.close() + return null +} diff --git a/src/main/memory/collector.ts b/src/main/memory/collector.ts index 171c07f175b..d2a8783a510 100644 --- a/src/main/memory/collector.ts +++ b/src/main/memory/collector.ts @@ -27,17 +27,13 @@ import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' import { app } from 'electron' -import type { - AppMemory, - MemorySnapshot, - HostMemory, - SessionMemory, - WorktreeMemory -} from '../../shared/types' +import type { AppMemory, MemorySnapshot, SessionMemory, WorktreeMemory } from '../../shared/types' import type { Store } from '../persistence' import { ORPHAN_WORKTREE_ID } from '../../shared/constants' import { listRegisteredPtys } from './pty-registry' import { enumerateWindowsProcessResources } from './windows-process-resource-collector' +import { collectHostMemory, fallbackHostMemory } from './host-memory' +import { getProcessMemoryMetric } from './process-memory-metric' export type MemorySnapshotStore = Pick @@ -95,26 +91,13 @@ function clampNumber(value: unknown): number { return Math.max(0, value) } -function hostMetrics(): HostMemory { - const total = clampNumber(os.totalmem()) - const free = clampNumber(os.freemem()) - const used = Math.max(0, total - free) - return { - totalMemory: total, - freeMemory: free, - usedMemory: used, - memoryUsagePercent: total > 0 ? (used / total) * 100 : 0, - cpuCoreCount: Math.max(1, os.cpus().length), - loadAverage1m: clampNumber(os.loadavg()[0]) - } -} - function emptySnapshot(): MemorySnapshot { const zero = { cpu: 0, memory: 0 } return { app: { ...zero, main: zero, renderer: zero, other: zero, history: [] }, worktrees: [], - host: hostMetrics(), + host: fallbackHostMemory(), + processMemoryMetric: getProcessMemoryMetric(), totalCpu: 0, totalMemory: 0, collectedAt: Date.now() @@ -352,7 +335,7 @@ function makeEmptyBucket( // ─── Main collection path ─────────────────────────────────────────── async function runSnapshot(store: MemorySnapshotStore): Promise { - const processIndex = await enumerateProcesses() + const [processIndex, host] = await Promise.all([enumerateProcesses(), collectHostMemory()]) const appBuckets = bucketElectronMetrics(processIndex) const ptys = listRegisteredPtys() @@ -446,7 +429,8 @@ async function runSnapshot(store: MemorySnapshotStore): Promise return { app: { ...appBuckets, history: readHistory(APP_HISTORY_KEY) }, worktrees, - host: hostMetrics(), + host, + processMemoryMetric: getProcessMemoryMetric(), totalCpu: appBuckets.cpu + sessionCpuTotal, totalMemory: appBuckets.memory + sessionMemoryTotal, collectedAt: now diff --git a/src/main/memory/host-memory.test.ts b/src/main/memory/host-memory.test.ts new file mode 100644 index 00000000000..e6735452da2 --- /dev/null +++ b/src/main/memory/host-memory.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import os from 'node:os' + +const { execFileMock, readFileMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ + execFile: ( + file: string, + args: string[], + options: unknown, + callback: (error: Error | null, stdout: string) => void + ) => execFileMock(file, args, options, callback) +})) + +vi.mock('node:fs/promises', () => ({ + readFile: (file: string, encoding: string) => readFileMock(file, encoding) +})) + +async function loadHostMemory() { + vi.resetModules() + return import('./host-memory') +} + +describe('host memory', () => { + beforeEach(() => { + vi.restoreAllMocks() + execFileMock.mockReset() + readFileMock.mockReset() + vi.spyOn(os, 'totalmem').mockReturnValue(1_000) + vi.spyOn(os, 'freemem').mockReturnValue(100) + vi.spyOn(os, 'cpus').mockReturnValue([{}, {}] as ReturnType) + vi.spyOn(os, 'loadavg').mockReturnValue([1.5, 1, 0.5]) + }) + + it('uses macOS memory-pressure availability instead of immediate free pages', async () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin') + execFileMock.mockImplementation((_file, _args, _options, callback) => + callback(null, 'System-wide memory free percentage: 79%') + ) + const { collectHostMemory } = await loadHostMemory() + + const host = await collectHostMemory() + + expect(host).toMatchObject({ + totalMemory: 1_000, + freeMemory: 100, + availableMemory: 790, + availableMemorySource: 'memory-pressure', + usedMemory: 210, + memoryUsagePercent: 21, + cpuCoreCount: 2, + loadAverage1m: 1.5 + }) + expect(execFileMock.mock.calls[0][0]).toBe('/usr/bin/memory_pressure') + expect(execFileMock.mock.calls[0][1]).toEqual(['-Q']) + }) + + it('falls back once when macOS availability cannot be read', async () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin') + execFileMock.mockImplementation((_file, _args, _options, callback) => + callback(new Error('unsupported'), '') + ) + const { collectHostMemory } = await loadHostMemory() + + const first = await collectHostMemory() + const second = await collectHostMemory() + + expect(first).toMatchObject({ + availableMemory: 100, + availableMemorySource: 'free-memory', + usedMemory: 900, + memoryUsagePercent: 90 + }) + expect(second.availableMemorySource).toBe('free-memory') + expect(execFileMock).toHaveBeenCalledTimes(1) + }) + + it('uses Linux MemAvailable and keeps the value within physical RAM', async () => { + vi.spyOn(os, 'platform').mockReturnValue('linux') + vi.spyOn(os, 'totalmem').mockReturnValue(4 * 1024 * 1024) + vi.spyOn(os, 'freemem').mockReturnValue(512 * 1024) + readFileMock.mockResolvedValue('MemTotal: 4096 kB\nMemAvailable: 2048 kB\n') + const { collectHostMemory } = await loadHostMemory() + + const host = await collectHostMemory() + + expect(host).toMatchObject({ + availableMemory: 2 * 1024 * 1024, + availableMemorySource: 'proc-meminfo', + usedMemory: 2 * 1024 * 1024, + memoryUsagePercent: 50 + }) + }) + + it('uses the bounded Node value on Windows', async () => { + vi.spyOn(os, 'platform').mockReturnValue('win32') + const { collectHostMemory } = await loadHostMemory() + + const host = await collectHostMemory() + + expect(host.availableMemory).toBe(100) + expect(host.availableMemorySource).toBe('free-memory') + expect(execFileMock).not.toHaveBeenCalled() + expect(readFileMock).not.toHaveBeenCalled() + }) +}) + +describe('host availability parsers', () => { + it('parses bounded macOS percentages', async () => { + const { parseDarwinAvailableMemory } = await loadHostMemory() + + expect(parseDarwinAvailableMemory('System-wide memory free percentage: 42%', 1_000)).toBe(420) + expect(parseDarwinAvailableMemory('System-wide memory free percentage: 101%', 1_000)).toBeNull() + }) + + it('parses Linux MemAvailable in KiB', async () => { + const { parseLinuxAvailableMemory } = await loadHostMemory() + + expect(parseLinuxAvailableMemory('MemAvailable: 1234 kB\n')).toBe(1234 * 1024) + expect(parseLinuxAvailableMemory('MemFree: 1234 kB\n')).toBeNull() + }) +}) diff --git a/src/main/memory/host-memory.ts b/src/main/memory/host-memory.ts new file mode 100644 index 00000000000..5622bd27d72 --- /dev/null +++ b/src/main/memory/host-memory.ts @@ -0,0 +1,138 @@ +import { execFile } from 'node:child_process' +import { readFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import type { HostAvailableMemorySource, HostMemory } from '../../shared/types' + +const MEMORY_PRESSURE_TIMEOUT_MS = 1_000 +const MEMORY_PRESSURE_MAX_BUFFER = 64 * 1024 +const KIB = 1024 + +let darwinAvailabilitySupported = true +let linuxAvailabilitySupported = true + +export async function collectHostMemory(): Promise { + const total = nonNegativeNumber(os.totalmem()) + const free = Math.min(total, nonNegativeNumber(os.freemem())) + const preferred = await readAvailableMemory(os.platform(), total) + const available = Math.min(total, Math.max(free, preferred?.bytes ?? free)) + const used = Math.max(0, total - available) + + return { + totalMemory: total, + freeMemory: free, + availableMemory: available, + availableMemorySource: preferred?.source ?? 'free-memory', + usedMemory: used, + memoryUsagePercent: total > 0 ? (used / total) * 100 : 0, + cpuCoreCount: Math.max(1, os.cpus().length), + loadAverage1m: nonNegativeNumber(os.loadavg()[0]) + } +} + +export function fallbackHostMemory(): HostMemory { + const total = nonNegativeNumber(os.totalmem()) + const free = Math.min(total, nonNegativeNumber(os.freemem())) + const used = Math.max(0, total - free) + return { + totalMemory: total, + freeMemory: free, + availableMemory: free, + availableMemorySource: 'free-memory', + usedMemory: used, + memoryUsagePercent: total > 0 ? (used / total) * 100 : 0, + cpuCoreCount: Math.max(1, os.cpus().length), + loadAverage1m: nonNegativeNumber(os.loadavg()[0]) + } +} + +export function parseDarwinAvailableMemory(stdout: string, total: number): number | null { + const match = /System-wide memory free percentage:\s*(\d+)%/.exec(stdout) + const percentage = Number.parseInt(match?.[1] ?? '', 10) + if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100 || total <= 0) { + return null + } + return Math.round((total * percentage) / 100) +} + +export function parseLinuxAvailableMemory(meminfo: string): number | null { + const match = /^MemAvailable:\s*(\d+)\s+kB$/m.exec(meminfo) + const kib = Number.parseInt(match?.[1] ?? '', 10) + if (!Number.isSafeInteger(kib) || kib < 0 || kib > Number.MAX_SAFE_INTEGER / KIB) { + return null + } + return kib * KIB +} + +async function readAvailableMemory( + platform: NodeJS.Platform, + total: number +): Promise<{ bytes: number; source: HostAvailableMemorySource } | null> { + if (platform === 'darwin' && darwinAvailabilitySupported) { + const bytes = await readDarwinAvailableMemory(total) + if (bytes !== null) { + return { bytes, source: 'memory-pressure' } + } + } + if (platform === 'linux' && linuxAvailabilitySupported) { + const bytes = await readLinuxAvailableMemory() + if (bytes !== null) { + return { bytes, source: 'proc-meminfo' } + } + } + return null +} + +async function readDarwinAvailableMemory(total: number): Promise { + try { + const stdout = await execFileText('/usr/bin/memory_pressure', ['-Q']) + const available = parseDarwinAvailableMemory(stdout, total) + if (available !== null) { + return available + } + } catch { + // The built-in command is unavailable on older or restricted hosts. + } + darwinAvailabilitySupported = false + return null +} + +async function readLinuxAvailableMemory(): Promise { + try { + const meminfo = await readFile(path.join(path.sep, 'proc', 'meminfo'), 'utf8') + const available = parseLinuxAvailableMemory(meminfo) + if (available !== null) { + return available + } + } catch { + // Non-procfs Linux environments fall back to Node's host value. + } + linuxAvailabilitySupported = false + return null +} + +function execFileText(file: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + file, + args, + { + encoding: 'utf8', + env: { ...process.env, LC_ALL: 'C', LANG: 'C' }, + maxBuffer: MEMORY_PRESSURE_MAX_BUFFER, + timeout: MEMORY_PRESSURE_TIMEOUT_MS + }, + (error, stdout) => { + if (error) { + reject(error) + return + } + resolve(String(stdout)) + } + ) + }) +} + +function nonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(0, value) : 0 +} diff --git a/src/main/memory/process-memory-metric.test.ts b/src/main/memory/process-memory-metric.test.ts new file mode 100644 index 00000000000..f19e64e165a --- /dev/null +++ b/src/main/memory/process-memory-metric.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { getProcessMemoryMetric } from './process-memory-metric' + +describe('process memory metric', () => { + it('declares RSS for Unix process sweeps', () => { + expect(getProcessMemoryMetric('darwin')).toBe('rss') + expect(getProcessMemoryMetric('linux')).toBe('rss') + }) + + it('declares working set for Windows process sweeps', () => { + expect(getProcessMemoryMetric('win32')).toBe('working-set') + }) +}) diff --git a/src/main/memory/process-memory-metric.ts b/src/main/memory/process-memory-metric.ts new file mode 100644 index 00000000000..79716cb1f8d --- /dev/null +++ b/src/main/memory/process-memory-metric.ts @@ -0,0 +1,8 @@ +import os from 'node:os' +import type { ProcessMemoryMetric } from '../../shared/types' + +export function getProcessMemoryMetric( + platform: NodeJS.Platform = os.platform() +): ProcessMemoryMetric { + return platform === 'win32' ? 'working-set' : 'rss' +} diff --git a/src/main/menu/register-app-menu.test.ts b/src/main/menu/register-app-menu.test.ts index fa636e8623c..8433fd93e8a 100644 --- a/src/main/menu/register-app-menu.test.ts +++ b/src/main/menu/register-app-menu.test.ts @@ -171,6 +171,11 @@ describe('registerAppMenu', () => { undefined as never, (isMac ? { ctrlKey: true } : { metaKey: true }) as Electron.KeyboardEvent ) + item?.click?.( + {} as never, + undefined as never, + { altKey: true, shiftKey: true } as Electron.KeyboardEvent + ) item?.click?.( {} as never, undefined as never, @@ -187,6 +192,13 @@ describe('registerAppMenu', () => { [{ includePrerelease: true, includePerfPrerelease: true }], [{ includePrerelease: false, includePerfPrerelease: true }], [{ includePrerelease: false, includePerfPrerelease: false }], + [ + { + includePrerelease: !isMac, + includePerfPrerelease: false, + ...(isMac ? { localBuild: true } : {}) + } + ], [{ includePrerelease: false, includePerfPrerelease: false }] ]) }) diff --git a/src/main/menu/register-app-menu.ts b/src/main/menu/register-app-menu.ts index 2f3a3d70a97..d1e43d4fb82 100644 --- a/src/main/menu/register-app-menu.ts +++ b/src/main/menu/register-app-menu.ts @@ -96,10 +96,15 @@ function buildAndApplyMenu(options: RegisterAppMenuOptions): void { event ) => { const modifierClick = !event.triggeredByAccelerator + const localBuild = isMac && modifierClick && event.altKey === true const includePerfPrerelease = - modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true) - const includePrerelease = modifierClick && event.shiftKey === true - onCheckForUpdates({ includePrerelease, includePerfPrerelease }) + !localBuild && modifierClick && (isMac ? event.metaKey === true : event.ctrlKey === true) + const includePrerelease = !localBuild && modifierClick && event.shiftKey === true + onCheckForUpdates({ + includePrerelease, + includePerfPrerelease, + ...(localBuild ? { localBuild: true } : {}) + }) } const checkForUpdatesItem: Electron.MenuItemConstructorOptions = { diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index 2f874e95746..9fb66705103 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -49,6 +49,8 @@ export async function readNativeChatTranscriptTailFile( consumedTo: number hasMore: boolean beforeOffset: number + malformedRecordCount?: number + oversizedRecordCount?: number }> { const end = Math.min((await stat(filePath)).size, endOffset ?? Number.MAX_SAFE_INTEGER) if (end === 0) { @@ -59,6 +61,9 @@ export async function readNativeChatTranscriptTailFile( let lineBytes = 0 let lineOversized = false let lifecycle: NativeChatTurnLifecycle | undefined + let malformedRecordCount = 0 + let oversizedRecordCount = 0 + let ignoreNextMalformedRecord = false try { const consumedTo = includeTrailingLine ? end : await findLastCompleteLineEnd(handle, end) if (consumedTo === 0) { @@ -67,6 +72,7 @@ export async function readNativeChatTranscriptTailFile( const newestFirst: { message: NativeChatMessage; offset: number }[] = [] const finalByte = Buffer.allocUnsafe(1) await handle.read(finalByte, 0, 1, consumedTo - 1) + ignoreNextMalformedRecord = finalByte[0] !== 0x0a let cursor = consumedTo - (finalByte[0] === 0x0a ? 1 : 0) while (cursor > 0 && newestFirst.length <= limit) { const start = Math.max(0, cursor - TAIL_CHUNK_BYTES) @@ -101,7 +107,9 @@ export async function readNativeChatTranscriptTailFile( ...(lifecycle ? { lifecycle } : {}), consumedTo, hasMore: limit > 0 && chronological.length > limit, - beforeOffset: selected[0]?.offset ?? end + beforeOffset: selected[0]?.offset ?? end, + ...(malformedRecordCount > 0 ? { malformedRecordCount } : {}), + ...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {}) } } finally { await handle.close() @@ -115,6 +123,7 @@ export async function readNativeChatTranscriptTailFile( if (lineBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { lineParts.length = 0 lineOversized = true + oversizedRecordCount++ return } lineParts.push(part) @@ -137,6 +146,17 @@ export async function readNativeChatTranscriptTailFile( if (!line) { return } + try { + JSON.parse(line) + } catch { + if (ignoreNextMalformedRecord) { + ignoreNextMalformedRecord = false + return + } + malformedRecordCount++ + return + } + ignoreNextMalformedRecord = false const fallbackId = transcriptFallbackId(filePath, lineOffset) // Why: scan the same bounded JSONL window for provider-authored lifecycle // records so reconnect snapshots can replay completion without guessing diff --git a/src/main/opencode-usage/scanner.ts b/src/main/opencode-usage/scanner.ts index 57a95df3a62..7d9b0ee1cdf 100644 --- a/src/main/opencode-usage/scanner.ts +++ b/src/main/opencode-usage/scanner.ts @@ -3,6 +3,7 @@ import { existsSync } from 'node:fs' import { readdir, realpath, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, isAbsolute, join, posix, win32 } from 'node:path' +import { yieldToEventLoop } from '../../shared/event-loop-yield' import type { Repo } from '../../shared/types' import { areWorktreePathsEqual } from '../ipc/worktree-logic' import Database from '../sqlite/sync-database' @@ -144,10 +145,6 @@ export async function getProcessedDatabaseInfo( } } -async function yieldToEventLoop(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)) -} - function getProjectJoin(db: Database.Database): string { return tableExists(db, 'project') && columnExists(db, 'session', 'project_id') ? 'LEFT JOIN project p ON p.id = s.project_id' diff --git a/src/main/persistence-right-sidebar-tab.test.ts b/src/main/persistence-right-sidebar-tab.test.ts new file mode 100644 index 00000000000..fcaf48afc4e --- /dev/null +++ b/src/main/persistence-right-sidebar-tab.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' + +// Why: persistence.ts touches electron at import time; a minimal stub keeps +// this normalizer test focused instead of booting the full Store fixture. +vi.mock('electron', () => ({ + app: { + getPath: () => '/tmp/orca-persistence-right-sidebar-tab-test' + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (plaintext: string) => Buffer.from(plaintext, 'utf-8'), + decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8') + } +})) + +import { normalizeRightSidebarTab } from './persistence' + +describe('normalizeRightSidebarTab', () => { + it.each(['explorer', 'search', 'vault', 'workspaces', 'source-control', 'checks', 'ports'])( + 'preserves the built-in %s tab', + (tab) => { + expect(normalizeRightSidebarTab(tab)).toBe(tab) + } + ) + + // Regression: pr-checks was missing from the allow-list, so the folder + // PR Checks tab silently reset to Explorer on every app restart. + it('preserves the folder-only pr-checks tab across restarts', () => { + expect(normalizeRightSidebarTab('pr-checks')).toBe('pr-checks') + }) + + it('preserves well-formed plugin panel tabs', () => { + expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/dashboard')).toBe( + 'plugin:orca-samples.my-plugin/dashboard' + ) + }) + + it('normalizes malformed plugin tabs to the default tab', () => { + expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin')).toBe('explorer') + expect(normalizeRightSidebarTab('plugin:orca-samples.my-plugin/panel/extra')).toBe('explorer') + expect(normalizeRightSidebarTab('plugin:My_Plugin/Panel!')).toBe('explorer') + }) + + it('normalizes unknown values to the default tab', () => { + expect(normalizeRightSidebarTab('bogus')).toBe('explorer') + expect(normalizeRightSidebarTab(undefined)).toBe('explorer') + expect(normalizeRightSidebarTab(42)).toBe('explorer') + }) +}) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 48495b7a1e0..593ba050b0b 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -72,6 +72,7 @@ import { normalizeProjectRuntimePreference } from '../shared/project-execution-runtime' import { projectHostSetupProjectionFromRepos } from '../shared/project-host-setup-projection' +import { isPluginPanelTabKey } from '../shared/plugins/plugin-manifest' import type { GitRemoteIdentity } from '../shared/git-remote-identity' import { buildTaskSourceContextFromRepo, @@ -781,18 +782,24 @@ function normalizeProjectOrderBy(projectOrderBy: unknown): PersistedState['ui'][ return getDefaultUIState().projectOrderBy } -function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { +export function normalizeRightSidebarTab(tab: unknown): PersistedState['ui']['rightSidebarTab'] { if ( tab === 'explorer' || tab === 'search' || tab === 'vault' || tab === 'workspaces' || + tab === 'pr-checks' || tab === 'source-control' || tab === 'checks' || tab === 'ports' ) { return tab } + // Why: plugin tabs are open-ended `plugin:./` keys; validate the + // shape so a persisted plugin tab doesn't reset to Explorer on restart. + if (typeof tab === 'string' && isPluginPanelTabKey(tab)) { + return tab + } return getDefaultUIState().rightSidebarTab } diff --git a/src/main/plugins/plugin-activation-policy.ts b/src/main/plugins/plugin-activation-policy.ts new file mode 100644 index 00000000000..9ef9bd59b3b --- /dev/null +++ b/src/main/plugins/plugin-activation-policy.ts @@ -0,0 +1,26 @@ +import { + getPluginActivationState, + type PluginConsentLists +} from '../../shared/plugins/plugin-consent-state' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +export function snapshotPluginConsentLists(source: { + getPluginConsents: () => Record + getDisabledPlugins: () => string[] +}): PluginConsentLists { + return { + pluginConsents: source.getPluginConsents(), + disabledPlugins: source.getDisabledPlugins() + } +} + +export function isPluginApproved( + enabled: boolean, + plugin: ValidDiscoveredPlugin, + lists: PluginConsentLists +): boolean { + return ( + enabled && + getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, lists) === 'approved' + ) +} diff --git a/src/main/plugins/plugin-approved-vm-recipes.ts b/src/main/plugins/plugin-approved-vm-recipes.ts new file mode 100644 index 00000000000..ab193a5280f --- /dev/null +++ b/src/main/plugins/plugin-approved-vm-recipes.ts @@ -0,0 +1,12 @@ +import type { OrcaVmRecipe } from '../../shared/types' +import type { PluginService } from './plugin-service' + +export async function getApprovedPluginVmRecipes( + pluginService?: PluginService +): Promise { + if (!pluginService) { + return [] + } + await pluginService.whenReady() + return pluginService.contentPacks.vmRecipes.list().map(({ recipe }) => recipe) +} diff --git a/src/main/plugins/plugin-artifact-validation.ts b/src/main/plugins/plugin-artifact-validation.ts new file mode 100644 index 00000000000..00e508b35af --- /dev/null +++ b/src/main/plugins/plugin-artifact-validation.ts @@ -0,0 +1,193 @@ +import { createReadStream } from 'node:fs' +import { realpath, stat } from 'node:fs/promises' +import { isAbsolute, relative, resolve, sep } from 'node:path' +import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact' + +export type PluginArtifactValidationResult = { ok: true } | { ok: false; error: string } + +export const PLUGIN_PANEL_ENTRY_MAX_BYTES = 10 * 1024 * 1024 +export const PLUGIN_WORKER_ENTRY_MAX_BYTES = 50 * 1024 * 1024 +const PLUGIN_ICON_MAX_BYTES = 2 * 1024 * 1024 +export const PLUGIN_LANGUAGE_PACK_MAX_BYTES = 5 * 1024 * 1024 +export const PLUGIN_VM_RECIPE_MAX_BYTES = 256 * 1024 +const PLUGIN_AGENT_PROFILE_MAX_BYTES = 1024 * 1024 + +type DeclaredArtifact = + | { label: string; path: string; kind: 'file'; maxBytes: number } + | { label: string; path: string; kind: 'directory' } + +function declaredArtifactPaths(manifest: PluginManifest): DeclaredArtifact[] { + return [ + ...(manifest.icon + ? [ + { + label: 'icon', + path: manifest.icon, + kind: 'file' as const, + maxBytes: PLUGIN_ICON_MAX_BYTES + } + ] + : []), + ...(manifest.main + ? [ + { + label: 'worker entry', + path: manifest.main, + kind: 'file' as const, + maxBytes: PLUGIN_WORKER_ENTRY_MAX_BYTES + } + ] + : []), + ...manifest.contributes.panels.map((panel) => ({ + label: `panel "${panel.id}" entry`, + path: panel.entry, + kind: 'file' as const, + maxBytes: PLUGIN_PANEL_ENTRY_MAX_BYTES + })), + ...manifest.contributes.languagePacks.map((languagePack) => ({ + label: `language pack "${languagePack.locale}"`, + path: languagePack.path, + kind: 'file' as const, + maxBytes: PLUGIN_LANGUAGE_PACK_MAX_BYTES + })), + ...manifest.contributes.vmRecipes.map((recipe) => ({ + label: 'VM recipe', + path: recipe.path, + kind: 'file' as const, + maxBytes: PLUGIN_VM_RECIPE_MAX_BYTES + })), + ...manifest.contributes.agents.map((agent) => ({ + label: 'agent profile', + path: agent.path, + kind: 'file' as const, + maxBytes: PLUGIN_AGENT_PROFILE_MAX_BYTES + })) + ] +} + +export async function resolveContainedPluginArtifact( + rootDir: string, + relativePath: string, + maxBytes = PLUGIN_WORKER_ENTRY_MAX_BYTES +): Promise { + const rootReal = await realpath(resolve(rootDir)) + return resolvePathFromRealRoot(rootDir, rootReal, relativePath, 'file', maxBytes) +} + +export async function readContainedPluginArtifactText( + rootDir: string, + relativePath: string, + maxBytes: number +): Promise { + const artifact = await resolveContainedPluginArtifact(rootDir, relativePath, maxBytes) + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(artifact)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > maxBytes) { + throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) + } + chunks.push(bytes) + } + return Buffer.concat(chunks, totalBytes).toString('utf8') +} + +async function resolvePathFromRealRoot( + rootDir: string, + rootReal: string, + relativePath: string, + kind: 'file' | 'directory', + maxBytes?: number +): Promise { + const artifactReal = await realpath(resolve(rootDir, ...relativePath.split(/[\\/]/))) + const fromRoot = relative(rootReal, artifactReal) + if ( + fromRoot.length === 0 || + isAbsolute(fromRoot) || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) + ) { + throw new Error('resolves outside the plugin directory') + } + const artifactStat = await stat(artifactReal) + if (kind === 'file' && !artifactStat.isFile()) { + throw new Error('is not a regular file') + } + if (kind === 'directory' && !artifactStat.isDirectory()) { + throw new Error('is not a directory') + } + if (kind === 'file' && maxBytes !== undefined && artifactStat.size > maxBytes) { + throw new Error(`exceeds the ${maxBytes}-byte artifact limit`) + } + return artifactReal +} + +/** Presence and containment checks are bounded by the manifest's declared artifacts. */ +export async function validateDeclaredPluginArtifacts( + rootDir: string, + manifest: PluginManifest +): Promise { + const artifacts = declaredArtifactPaths(manifest) + if (artifacts.length === 0) { + return { ok: true } + } + const seen = new Set() + let rootReal: string + try { + rootReal = await realpath(resolve(rootDir)) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + for (const artifact of artifacts) { + if (seen.has(artifact.path)) { + continue + } + seen.add(artifact.path) + try { + await resolvePathFromRealRoot( + rootDir, + rootReal, + artifact.path, + artifact.kind, + artifact.kind === 'file' ? artifact.maxBytes : undefined + ) + } catch (error) { + return { + ok: false, + error: `${artifact.label} ${artifact.path}: ${error instanceof Error ? error.message : String(error)}` + } + } + } + return { ok: true } +} + +/** Parses declared VM recipe artifacts at the immutable install boundary. */ +export async function validatePluginInstallContent( + rootDir: string, + manifest: PluginManifest +): Promise { + const vmRecipeIds = new Set() + for (const contribution of manifest.contributes.vmRecipes) { + try { + const recipe = parsePluginVmRecipeArtifact( + await readContainedPluginArtifactText( + rootDir, + contribution.path, + PLUGIN_VM_RECIPE_MAX_BYTES + ) + ) + if (vmRecipeIds.has(recipe.id)) { + throw new Error(`duplicate VM recipe id "${recipe.id}"`) + } + vmRecipeIds.add(recipe.id) + } catch (error) { + return { + ok: false, + error: `VM recipe ${contribution.path}: ${error instanceof Error ? error.message : String(error)}` + } + } + } + return { ok: true } +} diff --git a/src/main/plugins/plugin-atomic-file-write.test.ts b/src/main/plugins/plugin-atomic-file-write.test.ts new file mode 100644 index 00000000000..11407f7f781 --- /dev/null +++ b/src/main/plugins/plugin-atomic-file-write.test.ts @@ -0,0 +1,187 @@ +import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as FsPromises from 'node:fs/promises' +import { + renamePluginFileWithWindowsRetry, + writePluginFileAtomically +} from './plugin-atomic-file-write' + +// Windows AV/indexer locks cannot be provoked on CI, so queue the errno codes instead. +const locks = vi.hoisted(() => ({ codes: [] as string[] })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + rename: async (source: string, target: string) => { + const code = locks.codes.shift() + if (!code) { + return actual.rename(source, target) + } + throw Object.assign(new Error(`simulated ${code}`), { code }) + } + } +}) + +function withPlatform(platform: string): () => void { + const original = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: platform, configurable: true }) + return () => { + if (original) { + Object.defineProperty(process, 'platform', original) + } + } +} + +describe('writePluginFileAtomically', () => { + let dir: string + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'orca-plugin-atomic-')) + }) + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('writes contents and leaves no temp file behind', async () => { + const target = join(dir, 'current') + await writePluginFileAtomically(target, 'abc123') + expect(await readFile(target, 'utf8')).toBe('abc123') + expect(await readdir(dir)).toEqual(['current']) + }) + + it('replaces an existing file', async () => { + const target = join(dir, 'plugins.lock.json') + await writePluginFileAtomically(target, 'first') + await writePluginFileAtomically(target, 'second') + expect(await readFile(target, 'utf8')).toBe('second') + expect(await readdir(dir)).toEqual(['plugins.lock.json']) + }) + + it('applies the requested mode', async () => { + const target = join(dir, 'provenance.json') + await writePluginFileAtomically(target, '{}', { mode: 0o600 }) + const mode = (await stat(target)).mode & 0o777 + // Windows does not model POSIX permission bits. + if (process.platform !== 'win32') { + expect(mode).toBe(0o600) + } + }) + + it('cleans up the temp file when the write fails', async () => { + await expect(writePluginFileAtomically(join(dir, 'missing', 'x'), 'v')).rejects.toThrow() + expect(await readdir(dir)).toEqual([]) + }) + + it('cleans up the temp file when the rename gives up', async () => { + const restorePlatform = withPlatform('win32') + locks.codes = Array.from({ length: 6 }, () => 'EPERM') + try { + await expect(writePluginFileAtomically(join(dir, 'current'), 'v')).rejects.toMatchObject({ + code: 'EPERM' + }) + expect(await readdir(dir)).toEqual([]) + } finally { + restorePlatform() + locks.codes = [] + } + }) + + it('runs concurrent writers to one target without leaking temp files', async () => { + const target = join(dir, 'sources.json') + await Promise.all( + Array.from({ length: 8 }, (_unused, index) => + writePluginFileAtomically(target, `value-${index}`) + ) + ) + expect(await readdir(dir)).toEqual(['sources.json']) + expect(await readFile(target, 'utf8')).toMatch(/^value-\d$/) + }) +}) + +describe('renamePluginFileWithWindowsRetry', () => { + it('renames when the source exists', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-')) + try { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + await renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + expect(await readFile(join(dir, 'to'), 'utf8')).toBe('payload') + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('rethrows a non-retryable error', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-')) + try { + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'absent'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + describe('on Windows', () => { + let dir: string + let restorePlatform: () => void + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-win-')) + restorePlatform = withPlatform('win32') + }) + + afterEach(async () => { + restorePlatform() + locks.codes = [] + await rm(dir, { recursive: true, force: true }) + }) + + it.each(['EPERM', 'EACCES', 'EBUSY'])('retries past a transient %s lock', async (code) => { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + locks.codes = [code, code] + await renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + expect(await readFile(join(dir, 'to'), 'utf8')).toBe('payload') + expect(locks.codes).toEqual([]) + }) + + it('gives up after the last delay rather than looping forever', async () => { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + // One more lock than there are delays, so the loop must exit on the bound. + locks.codes = Array.from({ length: 6 }, () => 'EBUSY') + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'EBUSY' }) + expect(locks.codes).toEqual([]) + }) + + it('rethrows a non-retryable code without retrying', async () => { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + locks.codes = ['ENOSPC', 'ENOSPC'] + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'ENOSPC' }) + expect(locks.codes).toEqual(['ENOSPC']) + }) + }) + + it('does not retry off Windows', async () => { + const dir = await mkdtemp(join(tmpdir(), 'orca-plugin-rename-posix-')) + const restorePlatform = withPlatform('linux') + try { + await writePluginFileAtomically(join(dir, 'from'), 'payload') + locks.codes = ['EPERM', 'EPERM'] + await expect( + renamePluginFileWithWindowsRetry(join(dir, 'from'), join(dir, 'to')) + ).rejects.toMatchObject({ code: 'EPERM' }) + expect(locks.codes).toEqual(['EPERM']) + } finally { + restorePlatform() + locks.codes = [] + await rm(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/plugins/plugin-atomic-file-write.ts b/src/main/plugins/plugin-atomic-file-write.ts new file mode 100644 index 00000000000..e87b824f38c --- /dev/null +++ b/src/main/plugins/plugin-atomic-file-write.ts @@ -0,0 +1,55 @@ +import { randomUUID } from 'node:crypto' +import { rename, rm, writeFile } from 'node:fs/promises' + +/** + * Atomic write for plugin state files (lockfile, provenance, pointers, caches). + * + * Why the retry: on Windows the rename can fail with EPERM/EACCES/EBUSY while + * antivirus or an indexer holds the target open (issue #1507). The repo's + * existing `renameFileWithWindowsRetry` covers the same hazard but is sync; + * every plugin write path is async, so the backoff parks on a timer instead. + */ + +const WINDOWS_RENAME_RETRY_DELAYS_MS = [50, 100, 150, 200, 250] + +export type PluginAtomicWriteOptions = { mode?: number } + +export async function writePluginFileAtomically( + target: string, + contents: string, + options?: PluginAtomicWriteOptions +): Promise { + // Unique temp name so concurrent writers in one plugins dir cannot collide. + const temporary = `${target}.${process.pid}.${randomUUID()}.tmp` + try { + await writeFile(temporary, contents, { encoding: 'utf8', mode: options?.mode }) + await renamePluginFileWithWindowsRetry(temporary, target) + } finally { + await rm(temporary, { force: true }).catch(() => undefined) + } +} + +export async function renamePluginFileWithWindowsRetry( + source: string, + target: string +): Promise { + for (let attempt = 0; ; attempt += 1) { + try { + await rename(source, target) + return + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + const retryable = code === 'EPERM' || code === 'EACCES' || code === 'EBUSY' + if ( + process.platform !== 'win32' || + !retryable || + attempt >= WINDOWS_RENAME_RETRY_DELAYS_MS.length + ) { + throw error + } + await new Promise((resolve) => + setTimeout(resolve, WINDOWS_RENAME_RETRY_DELAYS_MS[attempt]) + ) + } + } +} diff --git a/src/main/plugins/plugin-audit-log.test.ts b/src/main/plugins/plugin-audit-log.test.ts new file mode 100644 index 00000000000..ab70de89da8 --- /dev/null +++ b/src/main/plugins/plugin-audit-log.test.ts @@ -0,0 +1,38 @@ +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PluginAuditLog } from './plugin-audit-log' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginAuditLog retention', () => { + it('rotates bounded segments while preserving recent entries across the boundary', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-audit-')) + roots.push(root) + const audit = new PluginAuditLog(root, { maxBytes: 240 }) + + for (let index = 0; index < 8; index += 1) { + await audit.record({ + ts: index, + actor: 'plugin:orca-samples.demo', + method: 'storage.set', + summary: `key=${index}`, + outcome: 'ok' + }) + } + + await expect(stat(join(root, 'audit.log'))).resolves.toMatchObject({ + isFile: expect.any(Function) + }) + await expect(stat(join(root, 'audit.log.1'))).resolves.toMatchObject({ + isFile: expect.any(Function) + }) + const recent = await audit.readRecent(3) + expect(recent.map((entry) => entry.ts)).toEqual([5, 6, 7]) + }) +}) diff --git a/src/main/plugins/plugin-audit-log.ts b/src/main/plugins/plugin-audit-log.ts new file mode 100644 index 00000000000..0b5f4cd3056 --- /dev/null +++ b/src/main/plugins/plugin-audit-log.ts @@ -0,0 +1,86 @@ +import { appendFile, mkdir, readFile, rename, rm, stat } from 'node:fs/promises' +import { dirname, join } from 'node:path' + +/** + * Append-only audit trail for host-API mutations performed on a plugin's + * behalf, with actor `plugin:`. One JSONL file so support and + * enterprise policy tooling can replay exactly what plugins did through the + * gated API. (Honest scope: worker code acting through its own Node access + * bypasses this — stated in the consent UI.) Mutation intents are awaited + * before their handler runs so an API-mediated write cannot outrun the log. + */ + +export type PluginAuditEntry = { + ts: number + actor: `plugin:${string}` + method: string + /** Bounded summary — never full params (they may contain user content). */ + summary: string + outcome: 'attempt' | 'ok' | 'error' +} + +export class PluginAuditLog { + private readonly filePath: string + private readonly rotatedFilePath: string + private readonly maxBytes: number + private writeChain: Promise = Promise.resolve() + private fileBytes: number | null = null + + constructor(pluginsDataDir: string, options: { maxBytes?: number } = {}) { + this.filePath = join(pluginsDataDir, 'audit.log') + this.rotatedFilePath = join(pluginsDataDir, 'audit.log.1') + this.maxBytes = options.maxBytes ?? 10 * 1024 * 1024 + } + + record(entry: PluginAuditEntry): Promise { + const write = this.writeChain.then(async () => { + await mkdir(dirname(this.filePath), { recursive: true }) + const line = `${JSON.stringify(entry)}\n` + if (this.fileBytes === null) { + this.fileBytes = await stat(this.filePath).then( + (file) => file.size, + () => 0 + ) + } + const lineBytes = Buffer.byteLength(line, 'utf8') + if (this.fileBytes > 0 && this.fileBytes + lineBytes > this.maxBytes) { + await rm(this.rotatedFilePath, { force: true }) + await rename(this.filePath, this.rotatedFilePath).catch((error) => { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error + } + }) + this.fileBytes = 0 + } + await appendFile(this.filePath, line, 'utf8') + this.fileBytes += lineBytes + }) + // Keep the serialization chain usable after a failed append while still + // exposing this write's failure to the mutation chokepoint. + this.writeChain = write.catch(() => undefined) + return write + } + + async flush(): Promise { + await this.writeChain + } + + async readRecent(limit = 200): Promise { + try { + const [rotated, current] = await Promise.all( + [this.rotatedFilePath, this.filePath].map((path) => readFile(path, 'utf8').catch(() => '')) + ) + const text = rotated + current + const lines = text.split('\n').filter((line) => line.length > 0) + return lines.slice(-limit).flatMap((line) => { + try { + return [JSON.parse(line) as PluginAuditEntry] + } catch { + return [] + } + }) + } catch { + return [] + } + } +} diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts new file mode 100644 index 00000000000..ab6543bc6b7 --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PluginBundledBootstrapResult } from './plugin-bundled-bootstrap' +import { PluginBundledBootstrapCoordinator } from './plugin-bundled-bootstrap-coordinator' + +const unchanged: PluginBundledBootstrapResult = { + installed: [], + unchanged: ['stablyai.orca-theme'], + errors: [] +} + +describe('PluginBundledBootstrapCoordinator', () => { + it('skips disabled requests and refreshes discovery only after publication', async () => { + let enabled = false + const bootstrap = vi + .fn() + .mockResolvedValueOnce(unchanged) + .mockResolvedValueOnce({ + installed: ['stablyai.orca-theme'], + unchanged: [], + errors: [] + }) + const refreshPlugins = vi.fn().mockResolvedValue(undefined) + const coordinator = new PluginBundledBootstrapCoordinator({ + root: 'resources', + userDataPath: 'user-data', + hostVersion: '1.4.0', + isEnabled: () => enabled, + refreshPlugins, + bootstrap + }) + + await expect(coordinator.request()).resolves.toBeNull() + enabled = true + await expect(coordinator.request()).resolves.toEqual(unchanged) + expect(refreshPlugins).not.toHaveBeenCalled() + await coordinator.request() + expect(refreshPlugins).toHaveBeenCalledOnce() + }) + + it('serializes overlapping startup and feature-toggle requests', async () => { + let active = 0 + let maximumActive = 0 + let releaseFirst: (() => void) | undefined + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + const bootstrap = vi.fn(async (): Promise => { + active += 1 + maximumActive = Math.max(maximumActive, active) + if (bootstrap.mock.calls.length === 1) { + await firstGate + } + active -= 1 + return unchanged + }) + const coordinator = new PluginBundledBootstrapCoordinator({ + root: 'resources', + userDataPath: 'user-data', + hostVersion: '1.4.0', + isEnabled: () => true, + refreshPlugins: vi.fn().mockResolvedValue(undefined), + bootstrap + }) + + const first = coordinator.request() + const second = coordinator.request() + await vi.waitFor(() => expect(bootstrap).toHaveBeenCalledTimes(1)) + releaseFirst?.() + await Promise.all([first, second]) + + expect(bootstrap).toHaveBeenCalledTimes(2) + expect(maximumActive).toBe(1) + }) +}) diff --git a/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts new file mode 100644 index 00000000000..74fae9b63e3 --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap-coordinator.ts @@ -0,0 +1,48 @@ +import { + bootstrapBundledPlugins, + type PluginBundledBootstrapResult +} from './plugin-bundled-bootstrap' + +type PluginBundledBootstrapRequest = Parameters[0] + +export class PluginBundledBootstrapCoordinator { + private readonly options: PluginBundledBootstrapRequest & { + isEnabled: () => boolean + refreshPlugins: () => Promise + bootstrap?: typeof bootstrapBundledPlugins + } + private pending: Promise = Promise.resolve() + + constructor(options: PluginBundledBootstrapCoordinator['options']) { + this.options = options + } + + request(): Promise { + const run = this.pending.then(() => this.runOnce()) + // Why: feature-toggle and startup requests can overlap; preserve their + // order even when one resource read fails. + this.pending = run.then( + () => undefined, + () => undefined + ) + return run + } + + private async runOnce(): Promise { + if (!this.options.isEnabled()) { + return null + } + const result = await (this.options.bootstrap ?? bootstrapBundledPlugins)({ + root: this.options.root, + userDataPath: this.options.userDataPath, + hostVersion: this.options.hostVersion, + ...(this.options.blockedPluginReason + ? { blockedPluginReason: this.options.blockedPluginReason } + : {}) + }) + if (result.installed.length > 0) { + await this.options.refreshPlugins() + } + return result + } +} diff --git a/src/main/plugins/plugin-bundled-bootstrap.test.ts b/src/main/plugins/plugin-bundled-bootstrap.test.ts new file mode 100644 index 00000000000..d5e2e64bd8f --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { hashPluginTree } from './plugin-content-hash' +import { readPluginLockfile } from './plugin-install' +import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap' + +const roots: string[] = [] + +async function tempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + return root +} + +async function writeBundle(root: string, name = 'Skills'): Promise<{ path: string; hash: string }> { + const path = 'stablyai.orca-skills' + const pluginRoot = join(root, path) + await mkdir(pluginRoot, { recursive: true }) + await writeFile( + join(pluginRoot, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'orca-skills', + publisher: 'stablyai', + name, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [] + }) + ) + const hashed = await hashPluginTree(pluginRoot) + if (!hashed.ok) { + throw new Error(hashed.error) + } + return { path, hash: hashed.hash } +} + +async function writeIndex(root: string, path: string, contentHash: string): Promise { + await writeFile( + join(root, 'bundled-plugins.json'), + JSON.stringify({ + version: 1, + plugins: [{ pluginKey: 'stablyai.orca-skills', path, contentHash }] + }) + ) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('bundled plugin bootstrap', () => { + it('installs release-indexed content once and keeps unchanged startup work bounded', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const bundle = await writeBundle(root) + await writeIndex(root, bundle.path, bundle.hash) + + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: [], unchanged: ['stablyai.orca-skills'], errors: [] }) + }) + + it('publishes an updated immutable bundle only when the indexed hash matches', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const first = await writeBundle(root) + await writeIndex(root, first.path, first.hash) + await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + const second = await writeBundle(root, 'Updated Skills') + await writeIndex(root, second.path, second.hash) + + const updated = await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + + expect(updated).toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + const lock = await readPluginLockfile(join(userDataPath, 'plugins')) + expect(lock.plugins['stablyai.orca-skills']?.contentHash).toBe(second.hash) + }) + + it('repairs a missing or modified bundled current version', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const bundle = await writeBundle(root) + await writeIndex(root, bundle.path, bundle.hash) + await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + const versionDir = join(userDataPath, 'plugins', 'stablyai.orca-skills', bundle.hash) + await writeFile(join(versionDir, 'orca-plugin.json'), '{}') + + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + + await rm(versionDir, { recursive: true, force: true }) + await expect( + bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ installed: ['stablyai.orca-skills'], unchanged: [], errors: [] }) + }) + + it('refuses mismatched release hashes before publication', async () => { + const root = await tempRoot('orca-bundled-resources-') + const userDataPath = await tempRoot('orca-bundled-user-data-') + const bundle = await writeBundle(root) + await writeIndex(root, bundle.path, 'f'.repeat(64)) + + const result = await bootstrapBundledPlugins({ root, userDataPath, hostVersion: '1.4.0' }) + + expect(result.installed).toEqual([]) + expect(result.errors[0]?.error).toContain('does not match its release index') + expect((await readPluginLockfile(join(userDataPath, 'plugins'))).plugins).toEqual({}) + }) + + it('resolves packaged and development resource roots without platform separators', () => { + expect( + resolveBundledPluginRoot({ + isPackaged: true, + resourcesPath: join('app', 'resources'), + appPath: join('repo', 'app') + }) + ).toBe(join('app', 'resources', 'plugins', 'launch')) + expect( + resolveBundledPluginRoot({ + isPackaged: false, + resourcesPath: join('app', 'resources'), + appPath: join('repo', 'app') + }) + ).toBe(join('repo', 'app', 'resources', 'plugins', 'launch')) + }) +}) diff --git a/src/main/plugins/plugin-bundled-bootstrap.ts b/src/main/plugins/plugin-bundled-bootstrap.ts new file mode 100644 index 00000000000..a7cfbbc1736 --- /dev/null +++ b/src/main/plugins/plugin-bundled-bootstrap.ts @@ -0,0 +1,154 @@ +import { readFile, realpath, stat } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import { z } from 'zod' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { pluginRelativeDirectorySchema } from '../../shared/plugins/plugin-manifest-fields' +import { isOfficialPluginIdentity } from '../../shared/plugins/plugin-marketplace' +import { getUserPluginsDir } from './plugin-discovery' +import { installBundledPlugin, readPluginLockfile } from './plugin-install' +import { inspectPluginInstallTree } from './plugin-install-staging' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { hashPluginTree } from './plugin-content-hash' + +export const BUNDLED_PLUGIN_INDEX_FILENAME = 'bundled-plugins.json' +const BUNDLED_PLUGIN_INDEX_MAX_BYTES = 64 * 1024 + +const bundledPluginIndexSchema = z + .object({ + version: z.literal(1), + plugins: z + .array( + z + .object({ + pluginKey: z + .string() + .refine(isQualifiedPluginKey, 'invalid qualified plugin identity') + .refine(isOfficialPluginIdentity, 'bundled plugins must use an official identity'), + path: pluginRelativeDirectorySchema, + contentHash: z.string().regex(/^[0-9a-f]{64}$/) + }) + .strict() + ) + .max(32) + }) + .strict() + .superRefine((index, ctx) => { + const keys = new Set() + for (const [entryIndex, plugin] of index.plugins.entries()) { + if (keys.has(plugin.pluginKey)) { + ctx.addIssue({ + code: 'custom', + path: ['plugins', entryIndex, 'pluginKey'], + message: 'duplicate bundled plugin identity' + }) + } + keys.add(plugin.pluginKey) + } + }) + +export type PluginBundledBootstrapResult = { + installed: string[] + unchanged: string[] + errors: { pluginKey: string; error: string }[] +} + +export function resolveBundledPluginRoot(options: { + isPackaged: boolean + resourcesPath: string + appPath: string +}): string { + return options.isPackaged + ? join(options.resourcesPath, 'plugins', 'launch') + : join(options.appPath, 'resources', 'plugins', 'launch') +} + +async function readBundledPluginIndex( + root: string +): Promise> { + const indexPath = join(root, BUNDLED_PLUGIN_INDEX_FILENAME) + const metadata = await stat(indexPath) + if (!metadata.isFile() || metadata.size > BUNDLED_PLUGIN_INDEX_MAX_BYTES) { + throw new Error(`bundled plugin index exceeds ${BUNDLED_PLUGIN_INDEX_MAX_BYTES} bytes`) + } + return bundledPluginIndexSchema.parse(JSON.parse(await readFile(indexPath, 'utf8'))) +} + +async function resolveBundlePath(root: string, path: string): Promise { + const [resolvedRoot, resolvedPath] = await Promise.all([ + realpath(root), + realpath(join(root, path)) + ]) + const fromRoot = relative(resolvedRoot, resolvedPath) + if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) { + throw new Error('bundled plugin path escapes the resource root') + } + return resolvedPath +} + +async function bundledInstallIsIntact( + pluginsDir: string, + pluginKey: string, + contentHash: string +): Promise { + const pluginDir = join(pluginsDir, pluginKey) + if ((await readPluginCurrentPointer(pluginDir).catch(() => null)) !== contentHash) { + return false + } + const hashed = await hashPluginTree(join(pluginDir, contentHash)) + return hashed.ok && hashed.hash === contentHash +} + +export async function bootstrapBundledPlugins(options: { + root: string + userDataPath: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + const index = await readBundledPluginIndex(options.root) + const pluginsDir = getUserPluginsDir(options.userDataPath) + const lock = await readPluginLockfile(pluginsDir) + const result: PluginBundledBootstrapResult = { installed: [], unchanged: [], errors: [] } + for (const entry of index.plugins) { + const locked = lock.plugins[entry.pluginKey] + if ( + locked?.source.kind === 'bundled' && + locked.source.bundleId === entry.pluginKey && + locked.contentHash === entry.contentHash && + (await bundledInstallIsIntact(pluginsDir, entry.pluginKey, entry.contentHash)) + ) { + result.unchanged.push(entry.pluginKey) + continue + } + try { + const sourcePath = await resolveBundlePath(options.root, entry.path) + const inspection = await inspectPluginInstallTree({ + rootDir: sourcePath, + hostVersion: options.hostVersion, + expectedPluginKey: entry.pluginKey + }) + if (!inspection.ok) { + throw new Error(inspection.error) + } + if (inspection.contentHash !== entry.contentHash) { + throw new Error('bundled plugin content does not match its release index') + } + const installed = await installBundledPlugin({ + pluginsDir, + sourcePath, + hostVersion: options.hostVersion, + expectedPluginKey: entry.pluginKey, + blockedPluginReason: options.blockedPluginReason + }) + if (!installed.ok) { + throw new Error(installed.error) + } + result.installed.push(entry.pluginKey) + } catch (error) { + result.errors.push({ + pluginKey: entry.pluginKey, + error: error instanceof Error ? error.message : String(error) + }) + } + } + return result +} diff --git a/src/main/plugins/plugin-command-invocation.ts b/src/main/plugins/plugin-command-invocation.ts new file mode 100644 index 00000000000..28ff762bfcc --- /dev/null +++ b/src/main/plugins/plugin-command-invocation.ts @@ -0,0 +1,13 @@ +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +export function assertPluginWorkerCommand(plugin: ValidDiscoveredPlugin, commandId: string): void { + const command = plugin.manifest.contributes.commands.find((entry) => entry.id === commandId) + if (!command) { + throw new Error(`plugin ${plugin.pluginKey} does not contribute command ${commandId}`) + } + // Declarative aliases are renderer-owned and must never cross the worker + // activation boundary, even if a compromised renderer invokes IPC directly. + if (command.action !== undefined) { + throw new Error(`plugin ${plugin.pluginKey} command ${commandId} is a built-in action alias`) + } +} diff --git a/src/main/plugins/plugin-command-registry.test.ts b/src/main/plugins/plugin-command-registry.test.ts new file mode 100644 index 00000000000..e05c6351441 --- /dev/null +++ b/src/main/plugins/plugin-command-registry.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginCommandRegistry } from './plugin-command-registry' + +function commandPlugin( + id: string, + contributes: { + commands: Record[] + keybindings?: Record[] + } +): ValidDiscoveredPlugin { + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id, + publisher: 'orca-samples', + name: id, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + ...(contributes.commands.some((command) => command.action === undefined) + ? { main: 'worker.js' } + : {}), + contributes, + capabilities: [] + }) + return { + pluginKey: `orca-samples.${id}`, + rootDir: `/plugins/${id}`, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, `content-${id}`), + consentContentHash: `content-${id}`, + contentHash: `content-${id}`, + isDev: false + } +} + +describe('PluginCommandRegistry', () => { + it('retains pending previews and exposes only approved commands', () => { + const plugin = commandPlugin('aliases', { + commands: [{ id: 'tasks', title: 'Open Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'mod+alt+t' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([plugin], () => false) + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toEqual([ + { + pluginKey: plugin.pluginKey, + id: 'tasks', + title: 'Open Tasks', + context: 'global', + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [{ key: 'Mod+Alt+T', when: 'global' }] + } + ]) + + registry.reconcile([plugin], () => true) + expect(registry.list()).toHaveLength(1) + }) + + it('projects worker commands and inherited worktree keybinding context', () => { + const plugin = commandPlugin('worker', { + commands: [{ id: 'create', title: 'Create Task', context: 'worktree' }], + keybindings: [{ command: 'create', key: 'Mod+Shift+A' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([plugin], () => true) + + expect(registry.list()).toMatchObject([ + { + context: 'worktree', + handler: { type: 'worker' }, + keybindings: [{ key: 'Mod+Shift+A', when: 'worktree' }] + } + ]) + }) + + it('errors approved plugins whose keybindings overlap', () => { + const global = commandPlugin('global', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'global' }] + }) + const worktree = commandPlugin('worktree', { + commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T', when: 'worktree' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([global, worktree], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.error(global.pluginKey)).toContain('conflicts') + expect(registry.error(worktree.pluginKey)).toContain('conflicts') + }) + + it('uses saved effective bindings to recover conflicting plugins', () => { + const first = commandPlugin('first', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const second = commandPlugin('second', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile( + [first, second], + () => true, + { 'plugin:orca-samples.first/tasks': ['Mod+Shift+T'] }, + 'linux' + ) + + expect(registry.list()).toHaveLength(2) + expect(registry.error(first.pluginKey)).toBeNull() + expect(registry.error(second.pluginKey)).toBeNull() + }) + + it('rejects conflicting saved bindings within one plugin', () => { + const plugin = commandPlugin('aliases', { + commands: [ + { id: 'tasks', title: 'Tasks', action: 'view.tasks' }, + { id: 'sidebar', title: 'Sidebar', action: 'sidebar.left.toggle' } + ] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile( + [plugin], + () => true, + { + 'plugin:orca-samples.aliases/tasks': ['Mod+Alt+T'], + 'plugin:orca-samples.aliases/sidebar': ['Mod+Alt+T'] + }, + 'linux' + ) + + expect(registry.list()).toEqual([]) + expect(registry.error(plugin.pluginKey)).toContain('conflicts') + }) + + it('detects cross-platform Mod and physical Ctrl conflicts', () => { + const portable = commandPlugin('portable', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const physical = commandPlugin('physical', { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Ctrl+Alt+T' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([portable, physical], () => true, {}, 'linux') + + expect(registry.list()).toEqual([]) + expect(registry.error(portable.pluginKey)).toContain('conflicts') + expect(registry.error(physical.pluginKey)).toContain('conflicts') + }) + + it('allows the same worktree-only chord after one plugin is disabled', () => { + const first = commandPlugin('first', { + commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const second = commandPlugin('second', { + commands: [{ id: 'tasks', title: 'Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'Mod+Alt+T' }] + }) + const registry = new PluginCommandRegistry() + + registry.reconcile([first, second], (plugin) => plugin === first) + + expect(registry.list()).toHaveLength(1) + expect(registry.error(first.pluginKey)).toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-command-registry.ts b/src/main/plugins/plugin-command-registry.ts new file mode 100644 index 00000000000..3b3af499ebc --- /dev/null +++ b/src/main/plugins/plugin-command-registry.ts @@ -0,0 +1,167 @@ +import { + pluginCommandKeybindingActionId, + type PluginCommandAliasActionId +} from '../../shared/plugins/plugin-command-actions' +import { getKeybindingConflictIdentity, type KeybindingOverrides } from '../../shared/keybindings' +import type { + PluginCommandContribution, + PluginManifest +} from '../../shared/plugins/plugin-manifest' +import type { PluginKeybindingContribution } from '../../shared/plugins/plugin-content-pack-contributions' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +export type PluginCommandKeybinding = { + key: string + when: 'global' | 'worktree' +} + +export type PluginCommandRegistration = { + pluginKey: string + id: string + title: string + context: 'global' | 'worktree' + handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' } + keybindings: PluginCommandKeybinding[] +} + +type CommandOwner = { + pluginKey: string + context: PluginCommandKeybinding['when'] + key: string +} + +export class PluginCommandRegistry { + private active: PluginCommandRegistration[] = [] + private readonly previews = new Map() + private readonly errors = new Map() + + list(): readonly PluginCommandRegistration[] { + return this.active + } + + preview(pluginKey: string): readonly PluginCommandRegistration[] { + return this.previews.get(pluginKey) ?? [] + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean, + overrides: KeybindingOverrides = {}, + platform: NodeJS.Platform = process.platform + ): void { + const candidates = discovered.filter( + (plugin): plugin is ValidDiscoveredPlugin => + !isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.commands.length > 0 + ) + const registrations = candidates.map((plugin) => ({ + pluginKey: plugin.pluginKey, + approved: isApproved(plugin), + commands: registrationsForManifest(plugin.pluginKey, plugin.manifest) + })) + + this.previews.clear() + this.errors.clear() + for (const plugin of registrations) { + this.previews.set(plugin.pluginKey, plugin.commands) + } + + const approved = registrations.filter((plugin) => plugin.approved) + const chordOwners = new Map() + for (const plugin of approved) { + for (const command of plugin.commands) { + for (const keybinding of effectiveCommandKeybindings(command, overrides)) { + const identity = getKeybindingConflictIdentity(keybinding.key, platform) + const owners = chordOwners.get(identity) ?? [] + owners.push({ + pluginKey: plugin.pluginKey, + context: keybinding.when, + key: keybinding.key + }) + chordOwners.set(identity, owners) + } + } + } + + const conflicted = new Set() + for (const owners of chordOwners.values()) { + for (let index = 0; index < owners.length; index += 1) { + for (let compared = index + 1; compared < owners.length; compared += 1) { + const first = owners[index]! + const second = owners[compared]! + if (!contextsOverlap(first.context, second.context)) { + continue + } + conflicted.add(first.pluginKey) + conflicted.add(second.pluginKey) + this.errors.set( + first.pluginKey, + `plugin keybinding ${first.key} conflicts with another plugin` + ) + this.errors.set( + second.pluginKey, + `plugin keybinding ${second.key} conflicts with another plugin` + ) + } + } + } + + this.active = approved + .filter((plugin) => !conflicted.has(plugin.pluginKey)) + .flatMap((plugin) => plugin.commands) + } +} + +function effectiveCommandKeybindings( + command: PluginCommandRegistration, + overrides: KeybindingOverrides +): PluginCommandKeybinding[] { + const override = overrides[pluginCommandKeybindingActionId(command.pluginKey, command.id)] + if (!Array.isArray(override)) { + return command.keybindings + } + return override.map((key) => ({ key, when: command.context })) +} + +function registrationsForManifest( + pluginKey: string, + manifest: PluginManifest +): PluginCommandRegistration[] { + return manifest.contributes.commands.map((command) => ({ + pluginKey, + id: command.id, + title: command.title, + context: command.context ?? 'global', + handler: + command.action === undefined + ? { type: 'worker' as const } + : { type: 'built-in' as const, action: command.action as PluginCommandAliasActionId }, + keybindings: keybindingsForCommand(command, manifest.contributes.keybindings) + })) +} + +function keybindingsForCommand( + command: PluginCommandContribution, + keybindings: readonly PluginKeybindingContribution[] +): PluginCommandKeybinding[] { + return keybindings + .filter((keybinding) => keybinding.command === command.id) + .map((keybinding) => ({ + key: keybinding.key, + when: keybinding.when ?? command.context ?? 'global' + })) +} + +function contextsOverlap( + first: PluginCommandKeybinding['when'], + second: PluginCommandKeybinding['when'] +): boolean { + return first === 'global' || second === 'global' || first === second +} diff --git a/src/main/plugins/plugin-content-hash.ts b/src/main/plugins/plugin-content-hash.ts new file mode 100644 index 00000000000..719c23fcb87 --- /dev/null +++ b/src/main/plugins/plugin-content-hash.ts @@ -0,0 +1,129 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { lstat, readdir } from 'node:fs/promises' +import { join, relative } from 'node:path' +import { pluginPathSegmentError } from '../../shared/plugins/plugin-path-safety' + +/** + * Deterministic hash of a plugin's file tree. The hash names the immutable + * install directory (`/plugins/.//`), so two + * installs of identical content share a name and a mutated install is + * detectable. Hashes relative paths + file bytes in sorted order; symlinks + * are refused outright (installed trees must be self-contained). + */ + +const MAX_PLUGIN_FILES = 2_000 +const MAX_PLUGIN_TOTAL_BYTES = 50 * 1024 * 1024 + +type PluginFile = { path: string; size: number } + +export type PluginTreeHashResult = + | { ok: true; hash: string; fileCount: number; totalBytes: number } + | { ok: false; error: string } + +async function collectFiles( + root: string, + dir: string, + files: PluginFile[], + counters: { entries: number; bytes: number } +): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + // Why: localeCompare ordering varies with host locale/ICU data; content + // addresses must sort identically on macOS, Linux, and Windows. + entries.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0)) + for (const entry of entries) { + if (dir === root && entry.name === '.git') { + continue + } + const segmentError = pluginPathSegmentError(entry.name) + if (segmentError) { + return `unsafe plugin path segment "${entry.name}": ${segmentError}` + } + const full = join(dir, entry.name) + const stat = await lstat(full) + counters.entries += 1 + if (counters.entries > MAX_PLUGIN_FILES) { + return `plugin exceeds the ${MAX_PLUGIN_FILES}-entry limit` + } + if (stat.isSymbolicLink()) { + return `symlink not allowed in plugin content: ${relative(root, full)}` + } + if (stat.isDirectory()) { + const error = await collectFiles(root, full, files, counters) + if (error) { + return error + } + } else if (stat.isFile()) { + counters.bytes += stat.size + if (counters.bytes > MAX_PLUGIN_TOTAL_BYTES) { + return `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit` + } + files.push({ path: full, size: stat.size }) + } else { + return `unsupported plugin entry type: ${relative(root, full)}` + } + } + return null +} + +async function hashFileBounded( + hash: ReturnType, + file: PluginFile +): Promise { + let bytesRead = 0 + for await (const chunk of createReadStream(file.path)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + bytesRead += bytes.byteLength + if (bytesRead > file.size || bytesRead > MAX_PLUGIN_TOTAL_BYTES) { + throw new Error(`plugin file changed while hashing: ${file.path}`) + } + hash.update(bytes) + } + if (bytesRead !== file.size) { + throw new Error(`plugin file changed while hashing: ${file.path}`) + } + return bytesRead +} + +export async function hashPluginTree(root: string): Promise { + const files: PluginFile[] = [] + try { + const counters = { entries: 0, bytes: 0 } + const error = await collectFiles(root, root, files, counters) + if (error) { + return { ok: false, error } + } + const hash = createHash('sha256') + // Why: every record is length-framed so path/content delimiters inside a + // plugin file cannot make two different trees share one hash preimage. + hash.update('orca-plugin-tree-v1\0') + let totalBytes = 0 + for (const file of files) { + totalBytes += file.size + if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) { + return { ok: false, error: `plugin exceeds the ${MAX_PLUGIN_TOTAL_BYTES}-byte limit` } + } + // Normalize separators so the same tree hashes identically on Windows. + const rel = relative(root, file.path).replaceAll('\\', '/') + hashLength(hash, Buffer.byteLength(rel, 'utf8')) + hash.update(rel, 'utf8') + hashLength(hash, file.size) + await hashFileBounded(hash, file) + } + // Hex (not base64) because the hash becomes a directory name. + return { + ok: true, + hash: hash.digest('hex'), + fileCount: files.length, + totalBytes + } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} + +function hashLength(hash: ReturnType, length: number): void { + const framedLength = Buffer.allocUnsafe(8) + framedLength.writeBigUInt64BE(BigInt(length)) + hash.update(framedLength) +} diff --git a/src/main/plugins/plugin-content-integrity.ts b/src/main/plugins/plugin-content-integrity.ts new file mode 100644 index 00000000000..19f68cae680 --- /dev/null +++ b/src/main/plugins/plugin-content-integrity.ts @@ -0,0 +1,58 @@ +import { hashPluginTree } from './plugin-content-hash' + +export type HashAddressedPluginContent = { + rootDir: string + contentHash: string | null +} + +export type PluginContentIntegrityResult = { ok: true } | { ok: false; error: string } + +/** Dev trees are intentionally mutable; installed hash-addressed trees are not. */ +export async function verifyHashAddressedPluginContent( + plugin: HashAddressedPluginContent +): Promise { + if (plugin.contentHash === null) { + return { ok: true } + } + const actual = await hashPluginTree(plugin.rootDir) + if (!actual.ok) { + return { ok: false, error: actual.error } + } + const matchesCurrentHash = actual.hash === plugin.contentHash + // Early P0 installs used a 128-bit SHA-256 prefix as the directory name. + // Honor that existing address while all new installs use the full digest. + const matchesLegacyPrefix = + plugin.contentHash.length === 32 && actual.hash.startsWith(plugin.contentHash) + if (!matchesCurrentHash && !matchesLegacyPrefix) { + return { + ok: false, + error: `content hash mismatch (expected ${plugin.contentHash}, got ${actual.hash})` + } + } + return { ok: true } +} + +/** Deduplicates the first lazy verification for each discovered install. */ +export class PluginContentVerifier { + private readonly verifications = new Map>() + + clear(): void { + this.verifications.clear() + } + + async verify(plugin: HashAddressedPluginContent & { pluginKey: string }): Promise { + // Why: a refresh can replace one same-key install while its old hash is + // still being verified. Cache by immutable content identity, never key. + const identity = JSON.stringify([plugin.pluginKey, plugin.rootDir, plugin.contentHash]) + let verification = this.verifications.get(identity) + if (!verification) { + verification = verifyHashAddressedPluginContent(plugin) + this.verifications.set(identity, verification) + } + const result = await verification + if (!result.ok) { + this.verifications.delete(identity) + throw new Error(`plugin ${plugin.pluginKey} failed integrity verification: ${result.error}`) + } + } +} diff --git a/src/main/plugins/plugin-content-pack-registry.test.ts b/src/main/plugins/plugin-content-pack-registry.test.ts new file mode 100644 index 00000000000..fe58cd3cc1d --- /dev/null +++ b/src/main/plugins/plugin-content-pack-registry.test.ts @@ -0,0 +1,108 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import { hashPluginTree } from './plugin-content-hash' +import { PluginContentPackRegistry } from './plugin-content-pack-registry' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginContentPackRegistry', () => { + it('activates all contributions from a plugin atomically', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-registry-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'locales')) + await Promise.all([ + writeFile( + join(rootDir, 'locales', 'invalid.json'), + JSON.stringify({ settings: { title: 42 } }) + ), + writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' })) + ]) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'mixed-content', + publisher: 'orca-samples', + name: 'Mixed Content', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + languagePacks: [ + { locale: 'es', path: 'locales/valid.json' }, + { locale: 'pt-BR', path: 'locales/invalid.json' } + ] + }, + capabilities: [] + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.mixed-content', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } + const registry = new PluginContentPackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + + expect(registry.error(plugin.pluginKey)).toContain('string or object') + expect(registry.languagePacks.list()).toEqual([]) + }) + + it('rolls back valid packs when a VM recipe from the same plugin is invalid', async () => { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-vm-')) + roots.push(rootDir) + await Promise.all([mkdir(join(rootDir, 'locales')), mkdir(join(rootDir, 'recipes'))]) + await Promise.all([ + writeFile(join(rootDir, 'locales', 'valid.json'), JSON.stringify({ settings: 'Ajustes' })), + writeFile( + join(rootDir, 'recipes', 'invalid.json'), + JSON.stringify({ schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', resume: 'up' }) + ) + ]) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'mixed-recipes', + publisher: 'orca-samples', + name: 'Mixed Recipes', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + languagePacks: [{ locale: 'es', path: 'locales/valid.json' }], + vmRecipes: [{ path: 'recipes/invalid.json' }] + }, + capabilities: [] + }) + const content = await hashPluginTree(rootDir) + if (!content.ok) { + throw new Error(content.error) + } + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.mixed-recipes', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, content.hash), + consentContentHash: content.hash, + contentHash: null, + isDev: true + } + const registry = new PluginContentPackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + + expect(registry.error(plugin.pluginKey)).toContain('suspend and resume') + expect(registry.languagePacks.list()).toEqual([]) + expect(registry.vmRecipes.list()).toEqual([]) + }) +}) diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts new file mode 100644 index 00000000000..c3901090c33 --- /dev/null +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -0,0 +1,95 @@ +import type { PluginContentVerifier } from './plugin-content-integrity' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' +import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' +import { PluginCommandRegistry } from './plugin-command-registry' +import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' +import type { KeybindingOverrides } from '../../shared/keybindings' + +export class PluginContentPackRegistry { + readonly languagePacks: PluginLanguagePackRegistry + readonly vmRecipes: PluginVmRecipeRegistry + readonly commands: PluginCommandRegistry + private readonly activationErrors = new Map() + + constructor(contentVerifier: PluginContentVerifier) { + this.languagePacks = new PluginLanguagePackRegistry(contentVerifier) + this.vmRecipes = new PluginVmRecipeRegistry() + this.commands = new PluginCommandRegistry() + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean, + keybindings: KeybindingOverrides = {} + ): Promise { + const approvedKeys = new Set( + discovered + .filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin)) + .filter(isApproved) + .map((plugin) => plugin.pluginKey) + ) + const excluded = new Set() + this.activationErrors.clear() + + await Promise.all( + discovered.map(async (plugin) => { + if ( + isInvalidDiscoveredPlugin(plugin) || + !approvedKeys.has(plugin.pluginKey) || + plugin.manifest.contributes.vmRecipes.length > 0 + ) { + return + } + try { + await verifyInstructionalPluginContent(plugin) + } catch (error) { + excluded.add(plugin.pluginKey) + this.activationErrors.set( + plugin.pluginKey, + error instanceof Error ? error.message : String(error) + ) + } + }) + ) + + while (true) { + const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean => + approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey) + await Promise.all([ + this.languagePacks.reconcile(discovered, approveAtomically), + this.vmRecipes.reconcile(discovered, approveAtomically), + this.commands.reconcile(discovered, approveAtomically, keybindings) + ]) + + let foundNewError = false + for (const pluginKey of approvedKeys) { + const error = this.registryError(pluginKey) + if (error && !excluded.has(pluginKey)) { + excluded.add(pluginKey) + this.activationErrors.set(pluginKey, error) + foundNewError = true + } + } + if (!foundNewError) { + break + } + } + } + + error(pluginKey: string): string | null { + return this.activationErrors.get(pluginKey) ?? this.registryError(pluginKey) + } + + private registryError(pluginKey: string): string | null { + return ( + this.languagePacks.error(pluginKey) ?? + this.vmRecipes.error(pluginKey) ?? + this.commands.error(pluginKey) + ) + } +} diff --git a/src/main/plugins/plugin-content-safety.test.ts b/src/main/plugins/plugin-content-safety.test.ts new file mode 100644 index 00000000000..eedeb42ef9f --- /dev/null +++ b/src/main/plugins/plugin-content-safety.test.ts @@ -0,0 +1,305 @@ +import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import { + PLUGIN_PANEL_ENTRY_MAX_BYTES, + validateDeclaredPluginArtifacts, + validatePluginInstallContent +} from './plugin-artifact-validation' +import { hashPluginTree } from './plugin-content-hash' +import { verifyHashAddressedPluginContent } from './plugin-content-integrity' +import { PluginService } from './plugin-service' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-content-test-')) + roots.push(root) + return root +} + +type ManifestOverrides = Omit, 'contributes'> & { + contributes?: Partial +} + +function manifest(overrides: ManifestOverrides = {}): PluginManifest { + const { contributes, ...manifestOverrides } = overrides + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [], + ...manifestOverrides, + contributes + }) +} + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('declared plugin artifacts', () => { + it('validates every content-pack file and directory before enablement', async () => { + const root = await tempRoot() + await Promise.all([ + mkdir(join(root, 'locales')), + mkdir(join(root, 'recipes')), + writeFile(join(root, 'agent.json'), '{}') + ]) + await Promise.all([ + writeFile(join(root, 'locales', 'pt-BR.json'), '{}'), + writeFile(join(root, 'recipes', 'vm.json'), '{}') + ]) + const pluginManifest = manifest({ + contributes: { + languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }], + vmRecipes: [{ path: 'recipes/vm.json' }], + agents: [{ path: 'agent.json' }] + } + }) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({ + ok: true + }) + }) + + it('requires declared files to exist and be regular files', async () => { + const root = await tempRoot() + await mkdir(join(root, 'panel.html')) + + const result = await validateDeclaredPluginArtifacts( + root, + manifest({ + main: 'missing-worker.js', + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [], + events: [] + } + }) + ) + + expect(result).toMatchObject({ ok: false }) + }) + + it('refuses a panel reached through an escaping directory link or junction', async () => { + const root = await tempRoot() + const outsideDir = await tempRoot() + const userDataPath = await tempRoot() + await writeFile(join(outsideDir, 'panel.html'), '

    outside

    ') + await symlink( + outsideDir, + join(root, 'escape'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + const pluginManifest = manifest({ + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'escape/panel.html' }], + commands: [], + events: [] + } + }) + await writeFile(join(root, 'orca-plugin.json'), JSON.stringify(pluginManifest)) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({ + ok: false + }) + + const pluginKey = `${pluginManifest.publisher}.${pluginManifest.id}` + const service = new PluginService({ + userDataPath, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ + [pluginKey]: fingerprintPluginConsent(pluginManifest) + }), + getDevPluginPaths: () => [root] + }) + try { + await service.initialize() + await expect(service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull() + } finally { + await service.dispose() + } + }) + + it('rejects a panel artifact too large to mount safely in a renderer', async () => { + const root = await tempRoot() + const panelPath = join(root, 'panel.html') + await writeFile(panelPath, '') + await truncate(panelPath, PLUGIN_PANEL_ENTRY_MAX_BYTES + 1) + const pluginManifest = manifest({ + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [], + events: [] + } + }) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('artifact limit') + }) + }) + + it('parses VM recipes at the immutable install boundary', async () => { + const root = await tempRoot() + await mkdir(join(root, 'recipes')) + await writeFile( + join(root, 'recipes', 'invalid.json'), + JSON.stringify({ + schemaVersion: 1, + id: 'cloud', + name: 'Cloud', + create: 'create', + suspend: 'suspend' + }) + ) + const pluginManifest = manifest({ + contributes: { vmRecipes: [{ path: 'recipes/invalid.json' }] } + }) + + await expect(validateDeclaredPluginArtifacts(root, pluginManifest)).resolves.toEqual({ + ok: true + }) + await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('suspend and resume') + }) + }) + + it('rejects duplicate VM recipe ids at the immutable install boundary', async () => { + const root = await tempRoot() + await mkdir(join(root, 'recipes')) + const recipe = JSON.stringify({ + schemaVersion: 1, + id: 'cloud', + name: 'Cloud', + create: 'create' + }) + await Promise.all([ + writeFile(join(root, 'recipes', 'one.json'), recipe), + writeFile(join(root, 'recipes', 'two.json'), recipe) + ]) + const pluginManifest = manifest({ + contributes: { + vmRecipes: [{ path: 'recipes/one.json' }, { path: 'recipes/two.json' }] + } + }) + + await expect(validatePluginInstallContent(root, pluginManifest)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('duplicate VM recipe id "cloud"') + }) + }) +}) + +describe('hash-addressed plugin content', () => { + it('uses unambiguous framing for paths and file contents', async () => { + const first = await tempRoot() + const second = await tempRoot() + await writeFile(join(first, 'a'), Buffer.from('x\0b\0y')) + await Promise.all([writeFile(join(second, 'a'), 'x'), writeFile(join(second, 'b'), 'y')]) + + const [firstHash, secondHash] = await Promise.all([ + hashPluginTree(first), + hashPluginTree(second) + ]) + + expect(firstHash).toMatchObject({ ok: true }) + expect(secondHash).toMatchObject({ ok: true }) + if (firstHash.ok && secondHash.ok) { + expect(firstHash.hash).not.toBe(secondHash.hash) + } + }) + + it('hashes and bounds nested .git directories as plugin content', async () => { + const root = await tempRoot() + const nestedGit = join(root, 'vendor', '.git') + await mkdir(nestedGit, { recursive: true }) + const entry = join(nestedGit, 'main.mjs') + await writeFile(entry, 'export default function activate() {}') + const initial = await hashPluginTree(root) + await writeFile(entry, 'export default function activate() { throw new Error("changed") }') + const changed = await hashPluginTree(root) + + expect(initial).toMatchObject({ ok: true }) + expect(changed).toMatchObject({ ok: true }) + if (initial.ok && changed.ok) { + expect(changed.hash).not.toBe(initial.hash) + } + + await truncate(entry, 50 * 1024 * 1024 + 1) + await expect(hashPluginTree(root)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('byte limit') + }) + }) + + it('does not let host locale collation change a content address', async () => { + const root = await tempRoot() + await writeFile(join(root, 'alpha.txt'), 'a') + await writeFile(join(root, 'zulu.txt'), 'z') + const expected = await hashPluginTree(root) + vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => -1) + + const withDifferentCollation = await hashPluginTree(root) + + expect(withDifferentCollation).toEqual(expected) + }) + + it.skipIf(process.platform === 'win32')( + 'rejects Windows-reserved tree entries cross-platform', + async () => { + const root = await tempRoot() + await writeFile(join(root, 'CON.txt'), 'reserved') + + await expect(hashPluginTree(root)).resolves.toMatchObject({ ok: false }) + } + ) + + it('detects content changed after its address was computed', async () => { + const root = await tempRoot() + const entry = join(root, 'panel.html') + await writeFile(entry, '

    original

    ') + const initial = await hashPluginTree(root) + expect(initial.ok).toBe(true) + if (!initial.ok) { + return + } + expect(initial.hash).toMatch(/^[0-9a-f]{64}$/) + await expect( + verifyHashAddressedPluginContent({ + rootDir: root, + contentHash: initial.hash.slice(0, 32) + }) + ).resolves.toEqual({ ok: true }) + + await writeFile(entry, '

    tampered

    ') + + await expect( + verifyHashAddressedPluginContent({ rootDir: root, contentHash: initial.hash }) + ).resolves.toMatchObject({ ok: false }) + }) + + it('rejects an oversized sparse file before reading it into memory', async () => { + const root = await tempRoot() + const oversized = join(root, 'oversized.bin') + await writeFile(oversized, '') + await truncate(oversized, 50 * 1024 * 1024 + 1) + + await expect(hashPluginTree(root)).resolves.toMatchObject({ + ok: false, + error: expect.stringContaining('byte limit') + }) + }) +}) diff --git a/src/main/plugins/plugin-current-pointer.ts b/src/main/plugins/plugin-current-pointer.ts new file mode 100644 index 00000000000..c299bed982d --- /dev/null +++ b/src/main/plugins/plugin-current-pointer.ts @@ -0,0 +1,49 @@ +import { createReadStream } from 'node:fs' +import { rm } from 'node:fs/promises' +import { join } from 'node:path' +import { writePluginFileAtomically } from './plugin-atomic-file-write' + +export const PLUGIN_CURRENT_POINTER_FILENAME = 'current' +export const PLUGIN_CURRENT_POINTER_MAX_BYTES = 128 + +/** Reads the tiny hash pointer through a cap so discovery cannot allocate a + * corrupt sparse file during startup. Missing pointers resolve to null. */ +export async function readPluginCurrentPointer(pluginDir: string): Promise { + const target = join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME) + const chunks: Buffer[] = [] + let totalBytes = 0 + try { + for await (const chunk of createReadStream(target)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_CURRENT_POINTER_MAX_BYTES) { + throw new Error('current-version pointer exceeds its size limit') + } + chunks.push(bytes) + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw error + } + return Buffer.concat(chunks, totalBytes).toString('utf8').trim() +} + +export async function writePluginCurrentPointer( + pluginDir: string, + contentHash: string +): Promise { + await writePluginFileAtomically(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), contentHash) +} + +export async function restorePluginCurrentPointer( + pluginDir: string, + previousContentHash: string | null +): Promise { + if (previousContentHash === null) { + await rm(join(pluginDir, PLUGIN_CURRENT_POINTER_FILENAME), { force: true }) + return + } + await writePluginCurrentPointer(pluginDir, previousContentHash) +} diff --git a/src/main/plugins/plugin-dev-watcher.test.ts b/src/main/plugins/plugin-dev-watcher.test.ts new file mode 100644 index 00000000000..77996fe3bd8 --- /dev/null +++ b/src/main/plugins/plugin-dev-watcher.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PluginDevWatcher } from './plugin-dev-watcher' + +afterEach(() => { + vi.useRealTimers() +}) + +describe('PluginDevWatcher', () => { + it('contains asynchronous watcher errors and requests a retrying refresh', async () => { + vi.useFakeTimers() + let onEvent!: (error: Error | null) => void + const unsubscribe = vi.fn().mockResolvedValue(undefined) + const subscribePath = vi.fn(async (_path, callback: typeof onEvent) => { + onEvent = callback + return { unsubscribe } + }) + const devWatcher = new PluginDevWatcher(subscribePath) + const refresh = vi.fn() + const onWatcherError = vi.fn() + devWatcher.start(['/plugins/demo'], refresh, onWatcherError) + await vi.waitFor(() => expect(subscribePath).toHaveBeenCalledOnce()) + + expect(() => onEvent(new Error('watch failed'))).not.toThrow() + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + expect(onWatcherError).toHaveBeenCalledOnce() + vi.advanceTimersByTime(300) + expect(refresh).toHaveBeenCalledOnce() + + devWatcher.dispose() + }) + + it('unsubscribes a subscription that resolves after disposal', async () => { + let resolveSubscription!: (value: { unsubscribe: () => Promise }) => void + const unsubscribe = vi.fn().mockResolvedValue(undefined) + const subscribePath = vi.fn( + () => + new Promise<{ unsubscribe: () => Promise }>((resolve) => { + resolveSubscription = resolve + }) + ) + const devWatcher = new PluginDevWatcher(subscribePath) + + devWatcher.start(['/plugins/demo'], vi.fn()) + devWatcher.dispose() + resolveSubscription({ unsubscribe }) + + await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce()) + }) + + it('does not spin refreshes when a missing path cannot be subscribed', async () => { + vi.useFakeTimers() + const subscribePath = vi.fn().mockRejectedValue(new Error('missing path')) + const refresh = vi.fn() + const onWatcherError = vi.fn() + const devWatcher = new PluginDevWatcher(subscribePath) + + devWatcher.start(['/plugins/missing'], refresh, onWatcherError) + await vi.waitFor(() => expect(onWatcherError).toHaveBeenCalledOnce()) + vi.advanceTimersByTime(10_000) + + expect(refresh).not.toHaveBeenCalled() + devWatcher.dispose() + }) +}) diff --git a/src/main/plugins/plugin-dev-watcher.ts b/src/main/plugins/plugin-dev-watcher.ts new file mode 100644 index 00000000000..ea77def417f --- /dev/null +++ b/src/main/plugins/plugin-dev-watcher.ts @@ -0,0 +1,108 @@ +import { + subscribeViaWatcherProcess, + type WatcherProcessSubscription +} from '../ipc/parcel-watcher-process' + +type SubscribePluginPath = ( + path: string, + onEvent: (error: Error | null) => void, + onInterruption: () => void +) => Promise + +const subscribePluginPath: SubscribePluginPath = (path, onEvent, onInterruption) => + subscribeViaWatcherProcess( + path, + (error) => onEvent(error), + {}, + { + onInterruption, + onTerminalError: onEvent + } + ) + +/** Owns debounced manifest/panel refresh watchers for mutable dev plugins. */ +export class PluginDevWatcher { + private readonly subscriptions: WatcherProcessSubscription[] = [] + private refreshTimer: ReturnType | null = null + private generation = 0 + + constructor(private readonly subscribePath: SubscribePluginPath = subscribePluginPath) {} + + start(devPaths: readonly string[], refresh: () => void, onWatcherError?: () => void): void { + const generation = ++this.generation + for (const devPath of devPaths) { + let subscription: WatcherProcessSubscription | null = null + let failedBeforeReady = false + const fail = (): void => { + if (generation !== this.generation) { + return + } + failedBeforeReady = true + if (subscription) { + this.removeSubscription(subscription) + void subscription.unsubscribe() + } + onWatcherError?.() + this.scheduleRefresh(refresh) + } + void this.subscribePath( + devPath, + (error) => { + if (error) { + fail() + } else if (generation === this.generation) { + this.scheduleRefresh(refresh) + } + }, + () => { + if (generation === this.generation) { + // The watcher process recovered, but changes during the gap were + // lost, so refresh the complete plugin projection once. + this.scheduleRefresh(refresh) + } + } + ) + .then((created) => { + subscription = created + if (generation !== this.generation || failedBeforeReady) { + void created.unsubscribe() + return + } + this.subscriptions.push(created) + }) + .catch(() => { + if (generation === this.generation) { + onWatcherError?.() + } + }) + } + } + + dispose(): void { + this.generation += 1 + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + this.refreshTimer = null + } + for (const subscription of this.subscriptions.splice(0)) { + void subscription.unsubscribe() + } + } + + private removeSubscription(subscription: WatcherProcessSubscription): void { + const index = this.subscriptions.indexOf(subscription) + if (index >= 0) { + this.subscriptions.splice(index, 1) + } + } + + private scheduleRefresh(refresh: () => void): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer) + } + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null + refresh() + }, 300) + } +} diff --git a/src/main/plugins/plugin-discovery.test.ts b/src/main/plugins/plugin-discovery.test.ts new file mode 100644 index 00000000000..685a622e90b --- /dev/null +++ b/src/main/plugins/plugin-discovery.test.ts @@ -0,0 +1,129 @@ +import { mkdir, mkdtemp, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { discoverPlugins, isInvalidDiscoveredPlugin } from './plugin-discovery' +import { PLUGIN_CURRENT_POINTER_MAX_BYTES } from './plugin-current-pointer' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function tempPluginsDir(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-discovery-')) + roots.push(root) + return root +} + +describe('installed plugin discovery identity', () => { + it('keeps the install directory identity when a manifest is invalid or mismatched', async () => { + const pluginsDir = await tempPluginsDir() + const installedKey = 'orca-samples.expected' + const hash = 'a'.repeat(64) + const versionDir = join(pluginsDir, installedKey, hash) + await mkdir(versionDir, { recursive: true }) + await writeFile(join(pluginsDir, installedKey, 'current'), hash) + await writeFile( + join(versionDir, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'different', + publisher: 'orca-samples', + name: 'Different', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { panels: [], commands: [], events: [] }, + capabilities: [] + }) + ) + + const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) + + expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true) + expect(plugin?.pluginKey).toBe(installedKey) + expect(plugin && 'error' in plugin ? plugin.error : '').toContain('does not match') + }) + + it('keeps a removable qualified identity when the current pointer is missing', async () => { + const pluginsDir = await tempPluginsDir() + const installedKey = 'orca-samples.broken' + await mkdir(join(pluginsDir, installedKey), { recursive: true }) + + const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) + + expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true) + expect(plugin?.pluginKey).toBe(installedKey) + }) + + it('rejects an oversized current pointer without an unbounded startup read', async () => { + const pluginsDir = await tempPluginsDir() + const installedKey = 'orca-samples.broken' + const pluginDir = join(pluginsDir, installedKey) + await mkdir(pluginDir, { recursive: true }) + const pointer = join(pluginDir, 'current') + await writeFile(pointer, '') + await truncate(pointer, PLUGIN_CURRENT_POINTER_MAX_BYTES + 1) + + const [plugin] = await discoverPlugins({ pluginsDir, devPluginPaths: [], hostVersion: '1.4.0' }) + + expect(plugin && isInvalidDiscoveredPlugin(plugin)).toBe(true) + expect(plugin?.pluginKey).toBe(installedKey) + }) +}) + +describe('instructional plugin discovery identity', () => { + it('changes dev consent when a VM recipe command changes', async () => { + const pluginsDir = await tempPluginsDir() + const devRoot = await tempPluginsDir() + await mkdir(join(devRoot, 'recipes')) + await writeFile( + join(devRoot, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: 'recipes', + publisher: 'orca-samples', + name: 'Recipes', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] }, + capabilities: [] + }) + ) + const recipePath = join(devRoot, 'recipes', 'cloud.json') + await writeFile( + recipePath, + JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v1' }) + ) + const [first] = await discoverPlugins({ + pluginsDir, + devPluginPaths: [devRoot], + hostVersion: '1.4.0' + }) + await writeFile( + recipePath, + JSON.stringify({ schemaVersion: 1, id: 'cloud', name: 'Cloud', create: 'create-v2' }) + ) + const [second] = await discoverPlugins({ + pluginsDir, + devPluginPaths: [devRoot], + hostVersion: '1.4.0' + }) + + expect(first && !isInvalidDiscoveredPlugin(first)).toBe(true) + expect(second && !isInvalidDiscoveredPlugin(second)).toBe(true) + if ( + !first || + !second || + isInvalidDiscoveredPlugin(first) || + isInvalidDiscoveredPlugin(second) + ) { + return + } + expect(second.consentContentHash).not.toBe(first.consentContentHash) + expect(second.consentFingerprint).not.toBe(first.consentFingerprint) + }) +}) diff --git a/src/main/plugins/plugin-discovery.ts b/src/main/plugins/plugin-discovery.ts new file mode 100644 index 00000000000..bffd31ef553 --- /dev/null +++ b/src/main/plugins/plugin-discovery.ts @@ -0,0 +1,254 @@ +import { readdir } from 'node:fs/promises' +import type { Dirent } from 'node:fs' +import { join } from 'node:path' +import { PLUGIN_CONTENT_HASH_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { + PLUGIN_MANIFEST_FILENAME, + isQualifiedPluginKey, + parsePluginManifest, + qualifiedPluginKey, + satisfiesOrcaEngineRange, + type PluginManifest +} from '../../shared/plugins/plugin-manifest' +import { + fingerprintPluginConsent, + hasInstructionalPluginContributions +} from '../../shared/plugins/plugin-consent-fingerprint' +import { validateDeclaredPluginArtifacts } from './plugin-artifact-validation' +import { readPluginManifestText } from './plugin-manifest-file' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { hashPluginTree } from './plugin-content-hash' + +export { PLUGIN_CURRENT_POINTER_FILENAME } from './plugin-current-pointer' + +const INSTALLED_PLUGIN_DISCOVERY_CONCURRENCY = 8 + +/** + * Discovery over the hash-addressed install layout: + * + * /plugins/./current ← text file naming the hash + * /plugins/.// ← immutable install tree + * + * plus dev-mode plugins loaded straight from arbitrary local directories. + * Discovery reads manifests and checks only their declared artifact paths — + * never plugin bytes or whole trees. Full content hashing stays lazy so + * startup cost is bounded by installed plugins plus declared entries. + */ + +export type ValidDiscoveredPlugin = { + /** Qualified `.` key. */ + pluginKey: string + rootDir: string + manifest: PluginManifest + /** Fingerprint of the capabilities and trusted-worker execution tier. */ + consentFingerprint: string + /** Immutable tree identity included in consent for instructional packs. */ + consentContentHash?: string | null + /** Content hash the install dir is named by; null for dev plugins. */ + contentHash: string | null + isDev: boolean +} + +export type InvalidDiscoveredPlugin = { + pluginKey?: string + rootDir: string + error: string + isDev: boolean +} + +export type DiscoveredPlugin = ValidDiscoveredPlugin | InvalidDiscoveredPlugin + +export function isInvalidDiscoveredPlugin( + plugin: DiscoveredPlugin +): plugin is InvalidDiscoveredPlugin { + return 'error' in plugin +} + +export function getUserPluginsDir(userDataPath: string): string { + return join(userDataPath, 'plugins') +} + +export function getPluginsDataDir(userDataPath: string): string { + return join(userDataPath, 'plugins-data') +} + +async function readManifestDir( + rootDir: string, + hostVersion: string, + isDev: boolean, + installedContentHash?: string +): Promise { + let rawText: string + try { + rawText = await readPluginManifestText(rootDir) + } catch (error) { + return { + rootDir, + error: + error instanceof Error && error.message.includes('exceeds') + ? error.message + : `missing ${PLUGIN_MANIFEST_FILENAME}`, + isDev + } + } + let raw: unknown + try { + raw = JSON.parse(rawText) + } catch (error) { + return { + rootDir, + error: `invalid JSON in ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}`, + isDev + } + } + const parsed = parsePluginManifest(raw) + if (!parsed.ok) { + return { rootDir, error: `invalid manifest: ${parsed.error}`, isDev } + } + const manifest = parsed.manifest + const pluginKey = qualifiedPluginKey(manifest) + if (!satisfiesOrcaEngineRange(hostVersion, manifest.engines.orca)) { + return { + pluginKey, + rootDir, + error: `requires Orca ${manifest.engines.orca} (this is ${hostVersion})`, + isDev + } + } + const artifacts = await validateDeclaredPluginArtifacts(rootDir, manifest) + if (!artifacts.ok) { + return { + pluginKey, + rootDir, + error: `invalid declared artifact: ${artifacts.error}`, + isDev + } + } + let consentContentIdentity: string | undefined + if (installedContentHash && hasInstructionalPluginContributions(manifest)) { + consentContentIdentity = installedContentHash + } + if (isDev && hasInstructionalPluginContributions(manifest)) { + const treeHash = await hashPluginTree(rootDir) + if (!treeHash.ok) { + return { pluginKey, rootDir, error: treeHash.error, isDev } + } + consentContentIdentity = treeHash.hash + } + return { + pluginKey, + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, consentContentIdentity), + consentContentHash: consentContentIdentity ?? null, + contentHash: null, + isDev + } +} + +async function readInstalledPlugin( + pluginDir: string, + dirName: string, + hostVersion: string +): Promise { + let contentHash: string + try { + contentHash = (await readPluginCurrentPointer(pluginDir)) ?? '' + } catch { + return { + pluginKey: dirName, + rootDir: pluginDir, + error: 'missing current-version pointer', + isDev: false + } + } + // The pointer names a sibling directory; refuse anything path-like so a + // corrupted pointer cannot address content outside the plugin dir. + if (!PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) { + return { + pluginKey: dirName, + rootDir: pluginDir, + error: 'corrupt current-version pointer', + isDev: false + } + } + const versionDir = join(pluginDir, contentHash) + const discovered = await readManifestDir(versionDir, hostVersion, false, contentHash) + if (isInvalidDiscoveredPlugin(discovered)) { + return { ...discovered, pluginKey: dirName } + } + // Why: the directory name is the install key (and the uninstall target); a + // mismatched manifest identity would let two dirs claim the same plugin. + if (discovered.pluginKey !== dirName) { + return { + pluginKey: dirName, + rootDir: versionDir, + error: `manifest identity "${discovered.pluginKey}" does not match install directory "${dirName}"`, + isDev: false + } + } + return { ...discovered, contentHash } +} + +async function readInstalledPlugins( + pluginsDir: string, + entries: readonly Dirent[], + hostVersion: string +): Promise { + const results = Array.from({ length: entries.length }) as DiscoveredPlugin[] + let nextIndex = 0 + const readers = Array.from( + { length: Math.min(INSTALLED_PLUGIN_DISCOVERY_CONCURRENCY, entries.length) }, + async () => { + while (nextIndex < entries.length) { + const index = nextIndex++ + const entry = entries[index]! + results[index] = await readInstalledPlugin( + join(pluginsDir, entry.name), + entry.name, + hostVersion + ) + } + } + ) + await Promise.all(readers) + return results +} + +export async function discoverPlugins(options: { + pluginsDir: string + devPluginPaths: readonly string[] + hostVersion: string +}): Promise { + const discovered: DiscoveredPlugin[] = [] + let entries: Dirent[] = [] + try { + entries = await readdir(options.pluginsDir, { withFileTypes: true }) + } catch { + // A missing plugins dir just means no plugins are installed yet. + } + const installedEntries = entries.filter( + (entry) => entry.isDirectory() && isQualifiedPluginKey(entry.name) + ) + // Installed manifests are independent immutable trees. Read them in + // a bounded pool so startup latency stays low without exhausting handles. + discovered.push( + ...(await readInstalledPlugins(options.pluginsDir, installedEntries, options.hostVersion)) + ) + for (const devPath of options.devPluginPaths) { + const plugin = await readManifestDir(devPath, options.hostVersion, true) + // A dev path that duplicates an installed plugin's identity wins — that + // is the point of dev mode — but two dev paths must not collide. + if (!isInvalidDiscoveredPlugin(plugin)) { + const collision = discovered.find( + (existing) => + !isInvalidDiscoveredPlugin(existing) && existing.pluginKey === plugin.pluginKey + ) + if (collision) { + discovered.splice(discovered.indexOf(collision), 1) + } + } + discovered.push(plugin) + } + return discovered +} diff --git a/src/main/plugins/plugin-enablement.test.ts b/src/main/plugins/plugin-enablement.test.ts new file mode 100644 index 00000000000..0ce3013d8a9 --- /dev/null +++ b/src/main/plugins/plugin-enablement.test.ts @@ -0,0 +1,132 @@ +import { tmpdir } from 'node:os' +import { describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../shared/constants' +import type { GlobalSettings } from '../../shared/types' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { Store } from '../persistence' +import { applyPluginConsent, applyPluginEnablement } from './plugin-enablement' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginService } from './plugin-service' + +const pluginKey = 'orca-samples.demo' +const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: {}, + capabilities: [] +}) + +function createStore(): { + store: Store + getSettings: () => GlobalSettings + updateSettings: ReturnType +} { + let settings = getDefaultSettings(tmpdir()) + const updateSettings = vi.fn((updates: Partial) => { + settings = { ...settings, ...updates } + }) + return { + store: { getSettings: () => settings, updateSettings } as unknown as Store, + getSettings: () => settings, + updateSettings + } +} + +function createPluginService( + getFingerprint: () => string, + overrides: Partial = {} +): PluginService { + return { + findValidPlugin: (requestedKey: string) => + requestedKey === pluginKey + ? { + pluginKey, + rootDir: tmpdir(), + manifest, + consentFingerprint: getFingerprint(), + consentContentHash: null, + contentHash: null, + isDev: true, + ...overrides + } + : null, + reconcileActivationState: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginService +} + +describe('applyPluginConsent', () => { + it('stores approval only for the fingerprint the user reviewed', async () => { + const harness = createStore() + const pluginService = createPluginService(() => 'sha256-reviewed') + + await applyPluginConsent({ + store: harness.store, + pluginService, + pluginKey, + reviewedFingerprint: 'sha256-reviewed', + decision: 'approve' + }) + + expect(harness.getSettings().pluginConsents[pluginKey]).toBe('sha256-reviewed') + expect(harness.getSettings().disabledPlugins).not.toContain(pluginKey) + }) + + it('rejects a stale review after a same-key plugin update without writing settings', async () => { + const harness = createStore() + let currentFingerprint = 'sha256-reviewed-v1' + const pluginService = createPluginService(() => currentFingerprint) + currentFingerprint = 'sha256-current-v2' + + await expect( + applyPluginConsent({ + store: harness.store, + pluginService, + pluginKey, + reviewedFingerprint: 'sha256-reviewed-v1', + decision: 'approve' + }) + ).rejects.toThrow('changed since its permissions were reviewed') + + expect(harness.updateSettings).not.toHaveBeenCalled() + expect(harness.getSettings().pluginConsents[pluginKey]).toBeUndefined() + }) + + it('allows a stale dialog to keep the newer plugin disabled', async () => { + const harness = createStore() + const pluginService = createPluginService(() => 'sha256-current-v2') + + await applyPluginConsent({ + store: harness.store, + pluginService, + pluginKey, + reviewedFingerprint: 'sha256-reviewed-v1', + decision: 'keep-disabled' + }) + + expect(harness.getSettings().disabledPlugins).toContain(pluginKey) + expect(pluginService.reconcileActivationState).toHaveBeenCalledOnce() + }) +}) + +describe('applyPluginEnablement', () => { + it('does not persist unknown plugin identities', async () => { + const harness = createStore() + const pluginService = createPluginService(() => 'sha256-current') + + await expect( + applyPluginEnablement({ + store: harness.store, + pluginService, + pluginKey: 'orca-samples.unknown', + enabled: false + }) + ).rejects.toThrow('unknown plugin') + + expect(harness.updateSettings).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/plugins/plugin-enablement.ts b/src/main/plugins/plugin-enablement.ts new file mode 100644 index 00000000000..44349ef087b --- /dev/null +++ b/src/main/plugins/plugin-enablement.ts @@ -0,0 +1,87 @@ +import { + normalizePluginConsents, + normalizePluginIdList +} from '../../shared/plugins/plugin-consent-state' +import type { Store } from '../persistence' +import type { PluginService } from './plugin-service' +import type { PluginConsentRequest } from '../../shared/plugins/plugin-consent-request' +import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' + +/** + * Single write path for consent + enablement. Consent is recorded as + * (qualified key → consent fingerprint) — never a bare id — so a capability + * expansion or addition of trusted Node code requires re-consent. The desktop + * IPC handlers and the headless RPC methods both route through here. + */ + +export type PluginConsentDecision = PluginConsentRequest['decision'] + +/** Records the user's consent-dialog answer. Approving stores the CURRENT + * consent fingerprint and clears any disable; declining disables so the plugin + * never re-prompts on later launches. */ +export async function applyPluginConsent(input: { + store: Store + pluginService: PluginService + pluginKey: PluginConsentRequest['pluginKey'] + reviewedFingerprint: PluginConsentRequest['reviewedFingerprint'] + decision: PluginConsentRequest['decision'] + originWebContentsId?: number +}): Promise { + const { store, pluginService, pluginKey } = input + const plugin = pluginService.findValidPlugin(pluginKey) + if (!plugin) { + throw new Error(`cannot record consent for unknown plugin ${pluginKey}`) + } + // Why: a same-key install can change while the dialog is open; never apply a + // decision to capabilities or a worker trust tier the user did not review. + if (input.decision === 'approve' && plugin.consentFingerprint !== input.reviewedFingerprint) { + throw new Error(`plugin ${pluginKey} changed since its permissions were reviewed`) + } + if (input.decision === 'approve') { + // Why: IPC and serve callers can bypass the renderer dialog, so main must + // prove every instructional byte is still reviewable before enabling it. + await verifyInstructionalPluginContent(plugin) + } + const settings = store.getSettings() + const disabled = new Set(normalizePluginIdList(settings.disabledPlugins)) + const consents = normalizePluginConsents(settings.pluginConsents) + if (input.decision === 'approve') { + consents[pluginKey] = plugin.consentFingerprint + disabled.delete(pluginKey) + } else { + disabled.add(pluginKey) + } + store.updateSettings( + { disabledPlugins: [...disabled], pluginConsents: consents }, + { notifyListeners: true, originWebContentsId: input.originWebContentsId } + ) + await pluginService.reconcileActivationState() +} + +/** Enables/disables an already-consented plugin. Enabling never bypasses + * consent: with missing or stale consent the plugin stays pending and the + * caller must run the consent flow instead. */ +export async function applyPluginEnablement(input: { + store: Store + pluginService: PluginService + pluginKey: string + enabled: boolean + originWebContentsId?: number +}): Promise { + const { store, pluginService, pluginKey, enabled } = input + if (!pluginService.findValidPlugin(pluginKey)) { + throw new Error(`cannot change enablement for unknown plugin ${pluginKey}`) + } + const settings = store.getSettings() + const disabled = new Set(normalizePluginIdList(settings.disabledPlugins)) + if (enabled) { + disabled.delete(pluginKey) + } else { + disabled.add(pluginKey) + } + store.updateSettings( + { disabledPlugins: [...disabled] }, + { notifyListeners: true, originWebContentsId: input.originWebContentsId } + ) + await pluginService.reconcileActivationState() +} diff --git a/src/main/plugins/plugin-event-bus.ts b/src/main/plugins/plugin-event-bus.ts new file mode 100644 index 00000000000..cee9e7825a5 --- /dev/null +++ b/src/main/plugins/plugin-event-bus.ts @@ -0,0 +1,42 @@ +import { PLUGIN_EVENT_PAYLOAD_SCHEMAS } from '../../shared/plugins/plugin-events' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' + +/** + * Server-side event filtering: plugins receive only events they subscribed + * to (manifest `contributes.events` or a runtime `events.subscribe` call) — + * never a firehose. Manifest subscriptions are durable activation triggers; + * dynamic subscriptions live only as long as the worker that made them. + */ + +export class PluginEventBus { + private readonly dynamicSubscriptions = new Map>() + + subscribe(pluginKey: string, events: PluginEventName[]): PluginEventName[] { + const existing = this.dynamicSubscriptions.get(pluginKey) ?? new Set() + for (const event of events) { + existing.add(event) + } + this.dynamicSubscriptions.set(pluginKey, existing) + return [...existing] + } + + isDynamicallySubscribed(pluginKey: string, event: PluginEventName): boolean { + return this.dynamicSubscriptions.get(pluginKey)?.has(event) ?? false + } + + /** Dynamic subscriptions die with the worker that registered them. */ + clear(pluginKey: string): void { + this.dynamicSubscriptions.delete(pluginKey) + } + + /** Validates and bounds an event payload before it reaches any plugin. */ + projectPayload( + event: PluginEventName, + payload: unknown + ): { ok: true; payload: unknown } | { ok: false; error: string } { + const parsed = PLUGIN_EVENT_PAYLOAD_SCHEMAS[event].safeParse(payload) + return parsed.success + ? { ok: true, payload: parsed.data } + : { ok: false, error: `malformed ${event} payload` } + } +} diff --git a/src/main/plugins/plugin-event-delivery.ts b/src/main/plugins/plugin-event-delivery.ts new file mode 100644 index 00000000000..15aac71b316 --- /dev/null +++ b/src/main/plugins/plugin-event-delivery.ts @@ -0,0 +1,48 @@ +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import type { PluginEventBus } from './plugin-event-bus' +import type { PluginWorkerController } from './plugin-worker-controller' + +export function deliverPluginEvent(options: { + event: PluginEventName + payload: unknown + plugins: readonly DiscoveredPlugin[] + eventBus: PluginEventBus + workerController: PluginWorkerController + isRuntimeApproved: (plugin: ValidDiscoveredPlugin) => boolean + logWarning: (pluginKey: string, line: string) => void +}): void { + const projected = options.eventBus.projectPayload(options.event, options.payload) + if (!projected.ok) { + return + } + for (const plugin of options.plugins) { + if (isInvalidDiscoveredPlugin(plugin) || !options.isRuntimeApproved(plugin)) { + continue + } + const manifestSubscribed = plugin.manifest.contributes.events.some( + (subscription) => subscription.on === options.event + ) + if (manifestSubscribed && plugin.manifest.main) { + void options.workerController + .ensure(plugin) + .then((handle) => handle.deliverEvent(options.event, projected.payload)) + .catch((error) => { + options.logWarning( + plugin.pluginKey, + `event ${options.event} dropped: ${error instanceof Error ? error.message : String(error)}` + ) + }) + } else if (options.eventBus.isDynamicallySubscribed(plugin.pluginKey, options.event)) { + options.workerController.deliverEventIfRunning( + plugin.pluginKey, + options.event, + projected.payload + ) + } + } +} diff --git a/src/main/plugins/plugin-git-repository.ts b/src/main/plugins/plugin-git-repository.ts new file mode 100644 index 00000000000..e8870f9e7fe --- /dev/null +++ b/src/main/plugins/plugin-git-repository.ts @@ -0,0 +1,57 @@ +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { + isAllowedPluginGitUrl, + PLUGIN_COMMIT_PATTERN +} from '../../shared/plugins/plugin-install-lockfile' + +const execFileAsync = promisify(execFile) +const PLUGIN_GIT_TIMEOUT_MS = 120_000 + +/** Runs system Git with argv-only invocation so credential helpers and SSH + * remotes work without exposing an executable remote-helper surface. */ +export async function runPluginGit(args: string[], cwd: string): Promise { + const { stdout } = await execFileAsync('git', args, { + cwd, + timeout: PLUGIN_GIT_TIMEOUT_MS, + windowsHide: true, + env: { + ...process.env, + // Existing non-interactive helpers and SSH agents still work, but a + // background marketplace refresh can never hang on a terminal prompt. + GIT_TERMINAL_PROMPT: '0' + } + }) + return stdout.trim() +} + +/** Checks out one Git ref into an empty destination and returns exact HEAD. */ +export async function checkoutPluginGitSource(input: { + url: string + ref: string + destination: string + workingDirectory: string +}): Promise { + if (!isAllowedPluginGitUrl(input.url)) { + throw new Error('plugin Git URL must use HTTPS or SSH') + } + const ref = input.ref.trim() + if (PLUGIN_COMMIT_PATTERN.test(ref)) { + await runPluginGit(['init', '--quiet', input.destination], input.workingDirectory) + await runPluginGit(['remote', 'add', 'origin', input.url], input.destination) + await runPluginGit(['fetch', '--quiet', '--depth', '1', 'origin', ref], input.destination) + await runPluginGit(['checkout', '--quiet', 'FETCH_HEAD'], input.destination) + } else { + const args = ['clone', '--quiet', '--depth', '1'] + if (ref.length > 0) { + args.push('--branch', ref) + } + args.push('--', input.url, input.destination) + await runPluginGit(args, input.workingDirectory) + } + const resolvedCommit = await runPluginGit(['rev-parse', 'HEAD'], input.destination) + if (!PLUGIN_COMMIT_PATTERN.test(resolvedCommit)) { + throw new Error('Git resolved an invalid commit identity') + } + return resolvedCommit +} diff --git a/src/main/plugins/plugin-host-call-adapter.ts b/src/main/plugins/plugin-host-call-adapter.ts new file mode 100644 index 00000000000..39c26eabdce --- /dev/null +++ b/src/main/plugins/plugin-host-call-adapter.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { executePluginHostCall, type ExecutePluginHostCallInput } from './plugin-host-methods' + +const pluginHostCallRequestSchema = z + .object({ + method: z.string().min(1).max(128), + params: z.unknown().optional() + }) + .strict() + +export type PluginHostCallRequest = z.infer + +export function isPluginHostCallRequest(request: unknown): request is PluginHostCallRequest { + return pluginHostCallRequestSchema.safeParse(request).success +} + +export type PluginHostCallPolicy = Pick< + ExecutePluginHostCallInput, + 'grantedCapabilities' | 'services' | 'audit' +> + +export type ResolvePluginHostCallPolicy = ( + pluginKey: string +) => PluginHostCallPolicy | Promise + +/** Validates the transport envelope, resolves all authority host-side, then + * enters the one capability/schema/audit execution chokepoint. */ +export async function executePluginHostCallRequest(input: { + /** Qualified identity already authenticated by the owning transport. */ + pluginKey: string + request: unknown + viaPanel: boolean + resolvePolicy: ResolvePluginHostCallPolicy +}): Promise { + if (!isQualifiedPluginKey(input.pluginKey)) { + return { ok: false, code: 'invalid_request', error: 'invalid qualified plugin key' } + } + const parsed = pluginHostCallRequestSchema.safeParse(input.request) + if (!parsed.success) { + return { ok: false, code: 'invalid_request', error: 'malformed plugin host call request' } + } + let policy: PluginHostCallPolicy + try { + policy = await input.resolvePolicy(input.pluginKey) + } catch { + return { ok: false, code: 'unavailable', error: 'plugin host policy is not available' } + } + return executePluginHostCall({ + pluginId: input.pluginKey, + method: parsed.data.method, + params: parsed.data.params, + viaPanel: input.viaPanel, + ...policy + }) +} diff --git a/src/main/plugins/plugin-host-conformance.test.ts b/src/main/plugins/plugin-host-conformance.test.ts new file mode 100644 index 00000000000..bc43dd741de --- /dev/null +++ b/src/main/plugins/plugin-host-conformance.test.ts @@ -0,0 +1,366 @@ +import { describe, expect, it, vi } from 'vitest' +import { PLUGIN_HOST_API_V0 } from '../../shared/plugins/plugin-host-api' +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import { + admitPluginPanelCall, + createPluginPanelCallAdmission +} from '../../shared/plugins/plugin-panel-call-admission' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import type { MethodHandler } from '../../relay/dispatcher' +import { + RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, + RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, + registerRelayPluginHostCallHandlers +} from '../../relay/plugin-host-call-handler' +import { + executePluginHostCallRequest, + type PluginHostCallPolicy, + type ResolvePluginHostCallPolicy +} from './plugin-host-call-adapter' +import type { PluginHostServices } from './plugin-host-methods' + +const PLUGIN_KEY = 'orca-samples.demo' +const WORKTREE_ID = 'repo-id::/Users/private/orca' +const TERMINAL_ID = 'terminal:local:one' + +type HostCallAdapter = (request: unknown, viaPanel: boolean) => Promise + +function createServices(): PluginHostServices { + return { + resolveActiveWorktreeContext: vi.fn().mockResolvedValue({ + worktreeId: WORKTREE_ID, + branch: 'main', + displayName: 'Orca', + path: '/Users/private/orca' + }), + listWorktreeTerminals: vi + .fn() + .mockResolvedValue([{ id: TERMINAL_ID, title: '/home/private/orca' }]), + sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }), + dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }), + storage: { + get: vi.fn().mockReturnValue('stored'), + set: vi.fn().mockReturnValue({ ok: true }), + delete: vi.fn(), + keys: vi.fn().mockReturnValue(['alpha']) + }, + secrets: { + get: vi.fn().mockReturnValue({ ok: true, value: 'secret' }), + set: vi.fn().mockReturnValue({ ok: true }), + delete: vi.fn() + }, + settings: { + getAll: vi.fn().mockReturnValue({ theme: 'dark' }), + set: vi.fn().mockReturnValue({ ok: true }) + }, + subscribeEvents: vi.fn().mockImplementation((_pluginKey, events) => events) + } +} + +function createPolicy( + grantedCapabilities: readonly PluginCapabilityKind[] | null, + services: PluginHostServices = createServices(), + audit = { record: vi.fn().mockResolvedValue(undefined) } +): PluginHostCallPolicy { + return { grantedCapabilities, services, audit } +} + +function createAdapters( + resolvePolicy: ResolvePluginHostCallPolicy, + limits?: { maxBytes?: number; maxMessages?: number; perMs?: number } +): Record { + const relayHandlers = new Map() + registerRelayPluginHostCallHandlers( + { onRequest: (method, handler) => relayHandlers.set(method, handler) }, + (context) => (context.clientId === 1 ? PLUGIN_KEY : null), + resolvePolicy, + { panelAdmission: createPluginPanelCallAdmission({ limits, now: () => 0 }) } + ) + const desktopAdmission = createPluginPanelCallAdmission({ limits, now: () => 0 }) + return { + 'desktop-main': async (request, viaPanel) => { + if (viaPanel) { + const admissionRefusal = admitPluginPanelCall(desktopAdmission, PLUGIN_KEY, request) + if (admissionRefusal) { + return admissionRefusal + } + } + return executePluginHostCallRequest({ + pluginKey: PLUGIN_KEY, + request, + viaPanel, + resolvePolicy + }) + }, + relay: async (request, viaPanel) => { + const registeredMethod = viaPanel + ? RELAY_PLUGIN_PANEL_HOST_CALL_METHOD + : RELAY_PLUGIN_WORKER_HOST_CALL_METHOD + return (await relayHandlers.get(registeredMethod)!(request as Record, { + clientId: 1, + isStale: () => false + })) as PluginPanelActionOutcome + } + } +} + +const successParams: Record = { + 'workspace.readContext': {}, + 'terminal.sendText': { terminalId: TERMINAL_ID, text: 'echo hi', enter: true }, + 'notifications.show': { title: 'Hello' }, + 'storage.get': { key: 'alpha' }, + 'storage.set': { key: 'alpha', value: 1 }, + 'storage.delete': { key: 'alpha' }, + 'storage.keys': {}, + 'secrets.get': { key: 'token' }, + 'secrets.set': { key: 'token', value: 'secret' }, + 'secrets.delete': { key: 'token' }, + 'settings.get': {}, + 'settings.set': { key: 'theme', value: 'dark' }, + 'events.subscribe': { events: ['worktree.created'] } +} + +describe('plugin host main/relay conformance', () => { + it('runs a granted success through both transports for all 13 v0 methods', async () => { + expect(PLUGIN_HOST_API_V0).toHaveLength(13) + expect(Object.keys(successParams).sort()).toEqual( + PLUGIN_HOST_API_V0.map((entry) => entry.name).sort() + ) + expect(PLUGIN_HOST_API_V0.every((entry) => entry.stability === 'experimental')).toBe(true) + expect(PLUGIN_HOST_API_V0.every((entry) => entry.scope.length > 0)).toBe(true) + + for (const spec of PLUGIN_HOST_API_V0) { + const policy = createPolicy([spec.capability]) + const resolvePolicy = vi.fn().mockResolvedValue(policy) + const outcomes = await Promise.all( + Object.values(createAdapters(resolvePolicy)).map((adapter) => + adapter({ method: spec.name, params: successParams[spec.name] }, spec.panel) + ) + ) + expect(outcomes, spec.name).toHaveLength(2) + expect(outcomes[0], spec.name).toEqual(outcomes[1]) + expect(outcomes[0], spec.name).toMatchObject({ ok: true }) + } + }) + + it('projects workspace context without host paths on main and relay', async () => { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['workspace:read'])) + for (const adapter of Object.values(createAdapters(resolvePolicy))) { + const outcome = await adapter({ method: 'workspace.readContext', params: {} }, true) + expect(outcome).toEqual({ + ok: true, + value: { + branch: 'main', + displayName: 'Orca', + terminals: [{ id: TERMINAL_ID }] + } + }) + expect(outcome).not.toHaveProperty('value.path') + expect(outcome).not.toHaveProperty('value.worktreeId') + } + }) + + const deniedCases: { + name: string + request: unknown + viaPanel: boolean + policy: () => PluginHostCallPolicy + code: string + }[] = [ + { + name: 'missing or stale consent', + request: { method: 'workspace.readContext', params: {} }, + viaPanel: true, + policy: () => createPolicy(null), + code: 'consent_required' + }, + { + name: 'missing capability', + request: { method: 'workspace.readContext', params: {} }, + viaPanel: true, + policy: () => createPolicy([]), + code: 'capability_denied' + }, + { + name: 'unknown method', + request: { method: 'workspace.erase', params: {} }, + viaPanel: false, + policy: () => createPolicy(['workspace:read']), + code: 'unknown_method' + }, + { + name: 'malformed params', + request: { + method: 'terminal.sendText', + params: { terminalId: TERMINAL_ID, text: '' } + }, + viaPanel: true, + policy: () => createPolicy(['terminal:send']), + code: 'invalid_params' + }, + { + name: 'panel-forbidden method', + request: { method: 'storage.get', params: { key: 'alpha' } }, + viaPanel: true, + policy: () => createPolicy(['storage']), + code: 'panel_forbidden' + }, + { + name: 'malformed result', + request: { + method: 'notifications.show', + params: { title: 'Hello' } + }, + viaPanel: true, + policy: () => { + const services = createServices() + services.dispatchPluginNotification = vi + .fn() + .mockResolvedValue({ delivered: 'yes' } as unknown as { delivered: boolean }) + return createPolicy(['notifications:show'], services) + }, + code: 'action_failed' + }, + { + name: 'mutation audit failure', + request: { + method: 'storage.set', + params: { key: 'alpha', value: 1 } + }, + viaPanel: false, + policy: () => + createPolicy(['storage'], createServices(), { + record: vi.fn().mockRejectedValue(new Error('disk full')) + }), + code: 'action_failed' + } + ] + + it.each(deniedCases)('returns identical $code codes for $name', async (testCase) => { + const outcomes: PluginPanelActionOutcome[] = [] + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockImplementation(() => testCase.policy()) + const adapter = createAdapters(resolvePolicy)[adapterName]! + outcomes.push(await adapter(testCase.request, testCase.viaPanel)) + } + expect(outcomes[0]).toMatchObject({ ok: false, code: testCase.code }) + expect(outcomes[1]).toMatchObject({ ok: false, code: testCase.code }) + expect(outcomes[0]).toEqual(outcomes[1]) + }) + + it('enforces the same per-plugin panel budget on desktop main and relay', async () => { + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) + const adapter = createAdapters(resolvePolicy, { + maxMessages: 1, + perMs: 10_000 + })[adapterName]! + + await expect( + adapter({ method: 'notifications.show', params: { title: 'first' } }, true) + ).resolves.toMatchObject({ ok: true }) + await expect( + adapter({ method: 'notifications.show', params: { title: 'second' } }, true) + ).resolves.toEqual({ + ok: false, + code: 'rate_limited', + error: 'too many panel requests' + }) + } + }) + + it('charges malformed and oversized panel traffic before schema parsing', async () => { + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['notifications:show'])) + const adapter = createAdapters(resolvePolicy, { + maxBytes: 128, + maxMessages: 2, + perMs: 10_000 + })[adapterName]! + + await expect( + adapter({ method: 'notifications.show', unexpected: true }, true) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + await expect( + adapter( + { + method: 'notifications.show', + params: { title: 'x'.repeat(256) } + }, + true + ) + ).resolves.toEqual({ + ok: false, + code: 'invalid_request', + error: 'panel message exceeds the size limit' + }) + await expect( + adapter({ method: 'notifications.show', params: { title: 'third' } }, true) + ).resolves.toEqual({ + ok: false, + code: 'rate_limited', + error: 'too many panel requests' + }) + expect(resolvePolicy).not.toHaveBeenCalled() + } + }) + + it('binds relay plugin identity to the requesting connection', async () => { + const relayHandlers = new Map() + const services = createServices() + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'], services)) + const resolveIdentity = vi + .fn() + .mockImplementation(({ clientId }: { clientId: number }) => + clientId === 7 ? PLUGIN_KEY : null + ) + registerRelayPluginHostCallHandlers( + { onRequest: (method, handler) => relayHandlers.set(method, handler) }, + resolveIdentity, + resolvePolicy + ) + const handler = relayHandlers.get(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD)! + + await expect( + handler( + { method: 'storage.get', params: { key: 'alpha' } }, + { clientId: 7, isStale: () => false } + ) + ).resolves.toMatchObject({ ok: true }) + expect(services.storage.get).toHaveBeenCalledWith(PLUGIN_KEY, 'alpha') + + await expect( + handler( + { method: 'storage.get', params: { key: 'alpha' } }, + { clientId: 8, isStale: () => false } + ) + ).resolves.toMatchObject({ ok: false, code: 'unavailable' }) + expect(resolvePolicy).toHaveBeenCalledTimes(1) + }) + + it('rejects malformed envelopes and client-supplied authority before policy resolution', async () => { + const requests = [ + { pluginKey: '../evil', method: 'storage.get', params: { key: 'alpha' } }, + { + pluginKey: PLUGIN_KEY, + method: 'storage.get', + params: { key: 'alpha' }, + grantedCapabilities: ['storage'] + }, + { + pluginKey: PLUGIN_KEY, + method: 'storage.get', + params: { key: 'alpha' }, + viaPanel: false + } + ] + for (const request of requests) { + for (const adapterName of ['desktop-main', 'relay']) { + const resolvePolicy = vi.fn().mockResolvedValue(createPolicy(['storage'])) + const outcome = await createAdapters(resolvePolicy)[adapterName]!(request, false) + expect(outcome).toMatchObject({ ok: false, code: 'invalid_request' }) + expect(resolvePolicy).not.toHaveBeenCalled() + } + } + }) +}) diff --git a/src/main/plugins/plugin-host-entry.ts b/src/main/plugins/plugin-host-entry.ts new file mode 100644 index 00000000000..cc6d40a55a9 --- /dev/null +++ b/src/main/plugins/plugin-host-entry.ts @@ -0,0 +1,41 @@ +/** + * Child-process entry for the out-of-process plugin worker. Forked with + * ELECTRON_RUN_AS_NODE, so this file must stay plain Node — no electron + * imports (directly or transitively). All logic lives in + * `plugin-host-runtime.ts`; this file only wires the fork IPC channel. + */ +import { createPluginWorkerRuntime } from './plugin-host-runtime' +import type { PluginWorkerChildMessage } from '../../shared/plugins/plugin-host-protocol' + +function sendToParent(message: PluginWorkerChildMessage): void { + process.send?.(message) +} + +const runtime = createPluginWorkerRuntime({ send: sendToParent }) + +process.on('message', (raw: unknown) => { + void runtime.handleMessage(raw) +}) + +// Why: third-party plugin code runs here; an escaped rejection must not leave +// a zombie worker. Report the crash so the parent can supervise/restart. +function dieFatally(error: unknown): void { + try { + sendToParent({ + type: 'fatal', + error: error instanceof Error ? (error.stack ?? error.message) : String(error) + }) + } catch { + // Channel already gone; nothing left to report to. + } + process.exit(1) +} + +process.on('uncaughtException', dieFatally) +process.on('unhandledRejection', dieFatally) + +// Why: if the parent dies without sending shutdown, the IPC channel closes; +// exit instead of lingering as an orphaned Node process. +process.on('disconnect', () => { + process.exit(0) +}) diff --git a/src/main/plugins/plugin-host-method-bindings.ts b/src/main/plugins/plugin-host-method-bindings.ts new file mode 100644 index 00000000000..b1073027287 --- /dev/null +++ b/src/main/plugins/plugin-host-method-bindings.ts @@ -0,0 +1,181 @@ +import { + getPluginHostMethodSpec, + PLUGIN_HOST_API_V0, + PLUGIN_TERMINAL_ID_MAX_LENGTH, + PLUGIN_WORKSPACE_LABEL_MAX_LENGTH, + PLUGIN_WORKSPACE_TERMINAL_LIMIT, + type PluginHostMethodSpec +} from '../../shared/plugins/plugin-host-api' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' + +export type PluginWorktreeContext = { + worktreeId: string + branch: string + displayName: string +} + +/** Structural service surface the facade delegates to. Desktop main binds it + * over runtime services; relay policy and conformance tests bind fakes. */ +export type PluginHostServices = { + resolveActiveWorktreeContext(): Promise + listWorktreeTerminals(worktreeId: string): Promise<{ id: string }[]> + sendTerminalText( + terminalId: string, + action: { text: string; enter: boolean } + ): Promise<{ accepted: boolean }> + dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> + storage: { + get(pluginId: string, key: string): unknown + set(pluginId: string, key: string, value: unknown): { ok: true } | { ok: false; error: string } + delete(pluginId: string, key: string): void + keys(pluginId: string): string[] + } + secrets: { + get( + pluginId: string, + key: string + ): { ok: true; value: string | null } | { ok: false; error: string } + set(pluginId: string, key: string, value: string): { ok: true } | { ok: false; error: string } + delete(pluginId: string, key: string): void + } + settings: { + getAll(pluginId: string): Record + set(pluginId: string, key: string, value: unknown): { ok: true } | { ok: false; error: string } + } + subscribeEvents(pluginId: string, events: PluginEventName[]): PluginEventName[] +} + +export type BoundPluginHostMethod = { + spec: PluginHostMethodSpec + handler: ( + params: unknown, + ctx: { pluginId: string; services: PluginHostServices } + ) => Promise +} + +function definePluginMethod( + name: string, + handler: BoundPluginHostMethod['handler'] +): [string, BoundPluginHostMethod] { + const spec = getPluginHostMethodSpec(name) + if (!spec) { + throw new Error(`no host API spec for method ${name}`) + } + return [name, { spec, handler }] +} + +const HANDLERS = new Map([ + definePluginMethod('workspace.readContext', async (_params, { services }) => { + const context = await services.resolveActiveWorktreeContext() + if (!context) { + return null + } + const terminals = await services.listWorktreeTerminals(context.worktreeId) + // Why: Orca worktree ids embed provider paths, so the public projection + // must select safe fields instead of spreading the internal context. + return { + branch: context.branch.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), + displayName: context.displayName.slice(0, PLUGIN_WORKSPACE_LABEL_MAX_LENGTH), + terminals: terminals + .filter( + (terminal) => + terminal.id.length > 0 && terminal.id.length <= PLUGIN_TERMINAL_ID_MAX_LENGTH + ) + .slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT) + .map((terminal) => ({ id: terminal.id })) + } + }), + definePluginMethod('terminal.sendText', async (params, { services }) => { + const { terminalId, text, enter } = params as { + terminalId: string + text: string + enter: boolean + } + const context = await services.resolveActiveWorktreeContext() + if (!context) { + throw new Error('no active worktree is available for terminal input') + } + // Why: terminal handles are provider-owned and can outlive focus changes; + // re-list the resolved worktree immediately before routing plugin input. + const terminals = await services.listWorktreeTerminals(context.worktreeId) + if (!terminals.some((terminal) => terminal.id === terminalId)) { + throw new Error('terminal is outside the active worktree') + } + const result = await services.sendTerminalText(terminalId, { text, enter }) + return { accepted: result.accepted } + }), + definePluginMethod('notifications.show', async (params, { pluginId, services }) => { + const { title, body } = params as { title: string; body?: string } + return services.dispatchPluginNotification({ pluginId, title, body }) + }), + definePluginMethod('storage.get', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + return { value: services.storage.get(pluginId, key) ?? null } + }), + definePluginMethod('storage.set', async (params, { pluginId, services }) => { + const { key, value } = params as { key: string; value: unknown } + const result = services.storage.set(pluginId, key, value) + if (!result.ok) { + throw new Error(result.error) + } + return { ok: true } + }), + definePluginMethod('storage.delete', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + services.storage.delete(pluginId, key) + return { ok: true } + }), + definePluginMethod('storage.keys', async (_params, { pluginId, services }) => { + return { keys: services.storage.keys(pluginId) } + }), + definePluginMethod('secrets.get', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + const result = services.secrets.get(pluginId, key) + if (!result.ok) { + throw new Error(result.error) + } + return { value: result.value } + }), + definePluginMethod('secrets.set', async (params, { pluginId, services }) => { + const { key, value } = params as { key: string; value: string } + const result = services.secrets.set(pluginId, key, value) + if (!result.ok) { + throw new Error(result.error) + } + return { ok: true } + }), + definePluginMethod('secrets.delete', async (params, { pluginId, services }) => { + const { key } = params as { key: string } + services.secrets.delete(pluginId, key) + return { ok: true } + }), + definePluginMethod('settings.get', async (_params, { pluginId, services }) => { + return { settings: services.settings.getAll(pluginId) } + }), + definePluginMethod('settings.set', async (params, { pluginId, services }) => { + const { key, value } = params as { key: string; value: unknown } + const result = services.settings.set(pluginId, key, value) + if (!result.ok) { + throw new Error(result.error) + } + return { ok: true } + }), + definePluginMethod('events.subscribe', async (params, { pluginId, services }) => { + const { events } = params as { events: PluginEventName[] } + return { subscribed: services.subscribeEvents(pluginId, events) } + }) +]) + +// Why: adding a facade schema without a binding must fail at module load, +// before a plugin can observe transport-specific behavior. +if (HANDLERS.size !== PLUGIN_HOST_API_V0.length) { + throw new Error('plugin host API spec table and handler bindings are out of sync') +} + +export function getBoundPluginHostMethod(name: string): BoundPluginHostMethod | null { + return HANDLERS.get(name) ?? null +} diff --git a/src/main/plugins/plugin-host-methods.test.ts b/src/main/plugins/plugin-host-methods.test.ts new file mode 100644 index 00000000000..19b12f43dd9 --- /dev/null +++ b/src/main/plugins/plugin-host-methods.test.ts @@ -0,0 +1,232 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' +import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' +import { executePluginHostCall, type PluginHostServices } from './plugin-host-methods' + +function createServices(storageSet: PluginHostServices['storage']['set']): PluginHostServices { + return { + resolveActiveWorktreeContext: vi.fn().mockResolvedValue(null), + listWorktreeTerminals: vi.fn().mockResolvedValue([]), + sendTerminalText: vi.fn().mockResolvedValue({ accepted: true }), + dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }), + storage: { + get: vi.fn(), + set: storageSet, + delete: vi.fn(), + keys: vi.fn().mockReturnValue([]) + }, + secrets: { + get: vi.fn().mockReturnValue({ ok: true, value: null }), + set: vi.fn().mockReturnValue({ ok: true }), + delete: vi.fn() + }, + settings: { + getAll: vi.fn().mockReturnValue({}), + set: vi.fn().mockReturnValue({ ok: true }) + }, + subscribeEvents: vi.fn().mockReturnValue([]) + } +} + +describe('executePluginHostCall mutation auditing', () => { + it('rejects prototype-sensitive storage keys before any host service call', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: '__proto__', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) + + expect(outcome).toMatchObject({ ok: false, code: 'invalid_params' }) + expect(storageSet).not.toHaveBeenCalled() + }) + + it('rejects non-JSON storage values before any host service call', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'created', value: new Date() }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) + + expect(outcome).toMatchObject({ ok: false, code: 'invalid_params' }) + expect(storageSet).not.toHaveBeenCalled() + }) + + it('fails closed before a mutation when the audit intent cannot be recorded', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'answer', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record: vi.fn().mockRejectedValue(new Error('disk full')) } + }) + + expect(outcome).toMatchObject({ ok: false, code: 'action_failed' }) + expect(storageSet).not.toHaveBeenCalled() + }) + + it('records an intent before the mutation and its outcome afterward', async () => { + const order: string[] = [] + const storageSet = vi.fn(() => { + order.push('mutation') + return { ok: true as const } + }) + const record = vi.fn(async (entry: { outcome: string }) => { + order.push(`audit:${entry.outcome}`) + }) + + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'answer', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet), + audit: { record } + }) + + expect(outcome).toEqual({ ok: true, value: { ok: true } }) + expect(order).toEqual(['audit:attempt', 'mutation', 'audit:ok']) + }) + + it('refuses mutations when no audit writer is configured', async () => { + const storageSet = vi.fn().mockReturnValue({ ok: true }) + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'storage.set', + params: { key: 'answer', value: 42 }, + viaPanel: false, + grantedCapabilities: ['storage'], + services: createServices(storageSet) + }) + + expect(outcome).toMatchObject({ ok: false, code: 'unavailable' }) + expect(storageSet).not.toHaveBeenCalled() + }) +}) + +function createTerminalHarness(terminalHandles: string[]): { + delegate: PluginRuntimeDelegate + services: PluginHostServices +} { + const delegate: PluginRuntimeDelegate = { + resolveActiveWorktreeContext: vi.fn().mockResolvedValue({ + worktreeId: 'worktree-1', + path: '/Users/private/repo', + branch: 'main', + displayName: 'Repo' + }), + listTerminals: vi.fn().mockResolvedValue({ + terminals: terminalHandles.map((handle) => ({ handle, title: null })) + }), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + dispatchPluginNotification: vi.fn().mockResolvedValue({ delivered: true }) + } + return { + delegate, + services: bindPluginHostServices({ + delegate, + pluginsDataDir: join(tmpdir(), 'plugin-host-methods-test'), + subscribeEvents: vi.fn().mockReturnValue([]) + }) + } +} + +async function sendTerminalText( + services: PluginHostServices, + terminalId: string +): ReturnType { + return executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'terminal.sendText', + params: { terminalId, text: 'echo hi', enter: true }, + viaPanel: true, + grantedCapabilities: ['terminal:send'], + services, + audit: { record: vi.fn().mockResolvedValue(undefined) } + }) +} + +describe('terminal.sendText explicit worktree routing', () => { + it('performs one bounded list and zero sends when the terminal is outside the worktree', async () => { + const { delegate, services } = createTerminalHarness(['terminal:local:other']) + + const outcome = await sendTerminalText(services, 'terminal:ssh:requested') + + expect(outcome).toMatchObject({ ok: false, code: 'action_failed' }) + expect(delegate.resolveActiveWorktreeContext).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledWith( + 'id:worktree-1', + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + expect(delegate.sendTerminal).not.toHaveBeenCalled() + }) + + it.each(['terminal:local:one', 'terminal:ssh:opaque-provider-id'])( + 'performs one bounded list and one send for provider-agnostic id %s', + async (terminalId) => { + const { delegate, services } = createTerminalHarness([terminalId]) + + const outcome = await sendTerminalText(services, terminalId) + + expect(outcome).toEqual({ ok: true, value: { accepted: true } }) + expect(delegate.resolveActiveWorktreeContext).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledTimes(1) + expect(delegate.listTerminals).toHaveBeenCalledWith( + 'id:worktree-1', + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + expect(delegate.sendTerminal).toHaveBeenCalledTimes(1) + expect(delegate.sendTerminal).toHaveBeenCalledWith(terminalId, { + text: 'echo hi', + enter: true + }) + expect(vi.mocked(delegate.listTerminals).mock.invocationCallOrder[0]!).toBeLessThan( + vi.mocked(delegate.sendTerminal).mock.invocationCallOrder[0]! + ) + } + ) + + it('bounds workspace.readContext and omits the provider path', async () => { + const handles = Array.from( + { length: PLUGIN_WORKSPACE_TERMINAL_LIMIT + 10 }, + (_, index) => `terminal:local:${index}` + ) + const { delegate, services } = createTerminalHarness(handles) + + const outcome = await executePluginHostCall({ + pluginId: 'orca-samples.demo', + method: 'workspace.readContext', + params: {}, + viaPanel: true, + grantedCapabilities: ['workspace:read'], + services + }) + + expect(outcome).toMatchObject({ + ok: true, + value: { branch: 'main', displayName: 'Repo' } + }) + expect(outcome).not.toHaveProperty('value.path') + expect(outcome).not.toHaveProperty('value.worktreeId') + expect(outcome.ok && (outcome.value as { terminals: unknown[] }).terminals).toHaveLength( + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + expect(delegate.listTerminals).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/plugins/plugin-host-methods.ts b/src/main/plugins/plugin-host-methods.ts new file mode 100644 index 00000000000..c4ffb00535f --- /dev/null +++ b/src/main/plugins/plugin-host-methods.ts @@ -0,0 +1,144 @@ +import { getBoundPluginHostMethod, type PluginHostServices } from './plugin-host-method-bindings' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { gatePluginHostCall as decidePluginHostCall } from '../../shared/plugins/plugin-capability-gate' +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import type { PluginAuditLog } from './plugin-audit-log' + +/** + * Host API v0 handler bindings — the one place plugin-originated calls + * (panel bridge, worker hostCall, serve RPC relay) execute. Handlers + * delegate to runtime services through the structural `PluginHostServices` + * interface, so this module stays electron-free and the relay conformance + * suite can run the identical chokepoint against a fake service set. + */ + +export type { PluginHostServices } from './plugin-host-method-bindings' + +export type ExecutePluginHostCallInput = { + /** Qualified plugin key, bound host-side from authenticated identity. */ + pluginId: string + method: string + params: unknown + /** True when the call arrives over the sandboxed panel bridge. */ + viaPanel: boolean + /** Consented capability kinds; null = unknown/disabled/consent-stale. */ + grantedCapabilities: readonly PluginCapabilityKind[] | null + services: PluginHostServices | null + audit?: Pick +} + +export async function executePluginHostCall( + input: ExecutePluginHostCallInput +): Promise { + if (!isQualifiedPluginKey(input.pluginId)) { + return { ok: false, code: 'invalid_request', error: 'invalid qualified plugin key' } + } + const gate = decidePluginHostCall( + { grantedCapabilities: input.grantedCapabilities, viaPanel: input.viaPanel }, + input.method + ) + if (!gate.granted) { + return { ok: false, code: gate.code, error: gate.error } + } + const bound = getBoundPluginHostMethod(input.method) + if (!bound) { + return { ok: false, code: 'unknown_method', error: `unknown host method: ${input.method}` } + } + const parsedParams = bound.spec.params.safeParse(input.params) + if (!parsedParams.success) { + const issue = parsedParams.error.issues[0] + const path = issue?.path.join('.') || '(root)' + return { + ok: false, + code: 'invalid_params', + error: `${path}: ${issue?.message ?? 'invalid params'}` + } + } + if (!input.services) { + return { ok: false, code: 'unavailable', error: 'runtime is not available' } + } + const auditMutation = async (outcome: 'attempt' | 'ok' | 'error'): Promise => { + if (bound.spec.mutation && input.audit) { + await input.audit.record({ + ts: Date.now(), + actor: `plugin:${input.pluginId}`, + method: input.method, + summary: summarizeParams(input.method, parsedParams.data), + outcome + }) + } + } + if (bound.spec.mutation) { + if (!input.audit) { + return { + ok: false, + code: 'unavailable', + error: 'mutation audit log is not available' + } + } + try { + // The intent is appended before the handler. If this write fails, the + // mutation is never attempted. + await auditMutation('attempt') + } catch { + return { + ok: false, + code: 'action_failed', + error: 'mutation audit log could not be written' + } + } + } + try { + const value = await bound.handler(parsedParams.data, { + pluginId: input.pluginId, + services: input.services + }) + const validated = bound.spec.result.safeParse(value) + if (!validated.success) { + await auditMutation('error').catch(() => undefined) + // A result-schema mismatch is a host bug; fail the call rather than + // leaking an unvalidated shape into plugin-facing transports. + return { + ok: false, + code: 'action_failed', + error: `internal: malformed ${input.method} result` + } + } + await auditMutation('ok').catch(() => undefined) + return { ok: true, value: validated.data } + } catch (error) { + await auditMutation('error').catch(() => undefined) + return { + ok: false, + code: 'action_failed', + error: error instanceof Error ? error.message : String(error) + } + } +} + +/** Bounded, content-free summaries for the audit log. */ +function summarizeParams(method: string, params: unknown): string { + const record = (typeof params === 'object' && params !== null ? params : {}) as Record< + string, + unknown + > + switch (method) { + case 'terminal.sendText': { + const text = typeof record.text === 'string' ? record.text : '' + return `terminal=${String(record.terminalId)} bytes=${Buffer.byteLength(text, 'utf8')} enter=${record.enter === true}` + } + case 'notifications.show': { + const title = typeof record.title === 'string' ? record.title : '' + return `titleChars=${title.length}` + } + case 'storage.set': + case 'storage.delete': + case 'secrets.set': + case 'secrets.delete': + case 'settings.set': + return `key=${String(record.key)}` + default: + return '' + } +} diff --git a/src/main/plugins/plugin-host-process.test.ts b/src/main/plugins/plugin-host-process.test.ts new file mode 100644 index 00000000000..2c4716f8e87 --- /dev/null +++ b/src/main/plugins/plugin-host-process.test.ts @@ -0,0 +1,135 @@ +import { EventEmitter } from 'node:events' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const processMocks = vi.hoisted(() => ({ fork: vi.fn() })) +vi.mock('node:child_process', () => ({ fork: processMocks.fork })) + +import { startPluginWorker } from './plugin-host-process' + +class FakeChild extends EventEmitter { + connected = true + stdout = new PassThrough() + stderr = new PassThrough() + send = vi.fn() + kill = vi.fn() +} + +function start(child: FakeChild, options: { eventTimeoutMs?: number } = {}) { + processMocks.fork.mockReturnValue(child) + return startPluginWorker({ + pluginId: 'orca-samples.demo', + rootDir: '/plugin', + mainEntry: 'worker.js', + entryPath: '/host.js', + grantedCapabilities: [], + executeHostCall: async () => ({ ok: true, value: null }), + log: vi.fn(), + ...options + }) +} + +beforeEach(() => { + processMocks.fork.mockReset() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('startPluginWorker', () => { + it('does not inherit Orca execArgv', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: [] }) + await pending + + expect(processMocks.fork).toHaveBeenCalledWith( + '/host.js', + [], + expect.objectContaining({ execArgv: [] }) + ) + }) + + it('replays an exit that happened before handle registration', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: ['run'] }) + const handle = await pending + child.emit('exit', 23) + const onExit = vi.fn() + + handle.onExit(onExit) + + expect(onExit).toHaveBeenCalledOnce() + expect(onExit).toHaveBeenCalledWith(23) + }) + + it('kills a live worker that disconnects its IPC channel', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: ['run'] }) + const handle = await pending + const command = handle.invokeCommand('run') + child.connected = false + + child.emit('disconnect') + + await expect(command).rejects.toThrow('disconnected') + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('counts delivered events as in flight until their acknowledgement', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: [] }) + const handle = await pending + + handle.deliverEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + + expect(handle.inFlightCount()).toBe(1) + child.emit('message', { type: 'eventAck', eventId: 0 }) + expect(handle.inFlightCount()).toBe(0) + }) + + it('kills a worker whose event handler never acknowledges completion', async () => { + vi.useFakeTimers() + const child = new FakeChild() + const pending = start(child, { eventTimeoutMs: 25 }) + child.emit('message', { type: 'ready', commands: [] }) + const handle = await pending + + handle.deliverEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + await vi.advanceTimersByTimeAsync(25) + + expect(handle.inFlightCount()).toBe(0) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) + + it('kills a worker that exceeds the pending event cap', async () => { + const child = new FakeChild() + const pending = start(child) + child.emit('message', { type: 'ready', commands: [] }) + const handle = await pending + + for (let index = 0; index < 65; index += 1) { + handle.deliverEvent('agent.status.changed', { + worktreeId: null, + paneKey: `pane-${index}`, + state: 'working', + receivedAt: Date.now() + }) + } + + expect(handle.inFlightCount()).toBe(64) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + }) +}) diff --git a/src/main/plugins/plugin-host-process.ts b/src/main/plugins/plugin-host-process.ts new file mode 100644 index 00000000000..0c289d502ad --- /dev/null +++ b/src/main/plugins/plugin-host-process.ts @@ -0,0 +1,332 @@ +import { fork, type ChildProcess } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { + PLUGIN_WORKER_INVOKE_TIMEOUT_MS, + PLUGIN_WORKER_READY_TIMEOUT_MS, + pluginWorkerChildMessageSchema, + type PluginWorkerParentMessage +} from '../../shared/plugins/plugin-host-protocol' +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { buildPluginWorkerEnv } from './plugin-worker-env' +import { pipePluginWorkerOutput } from './plugin-worker-output-buffer' + +// Grace between the shutdown message and SIGKILL: long enough for plugin +// cleanup, short enough that disable/quit never feels stuck. +const PLUGIN_WORKER_SHUTDOWN_GRACE_MS = 2_000 +const PLUGIN_WORKER_EVENT_TIMEOUT_MS = 5 * 60_000 +const PLUGIN_WORKER_MAX_PENDING_EVENTS = 64 + +export type PluginWorkerLogSink = (level: 'info' | 'warn' | 'error', line: string) => void + +/** Executes a worker-originated host API call; the outcome is relayed back + * over the fork channel as a hostResult message. */ +export type PluginWorkerHostCallExecutor = ( + method: string, + params: unknown +) => Promise + +export type PluginWorkerHandle = { + /** Command ids the worker registered on activate (⊆ manifest commands). */ + commands: readonly string[] + invokeCommand(commandId: string, args?: unknown): Promise + deliverEvent(event: PluginEventName, payload: unknown): void + /** Milliseconds timestamp of the last completed work (for idle reap). */ + lastActivityAt(): number + inFlightCount(): number + dispose(): Promise + kill(): void + onExit(callback: (code: number | null) => void): void +} + +export type StartPluginWorkerOptions = { + pluginId: string + rootDir: string + mainEntry: string + /** Absolute path to the compiled plugin-host-entry.js, resolved by caller. */ + entryPath: string + grantedCapabilities: readonly PluginCapabilityKind[] + executeHostCall: PluginWorkerHostCallExecutor + log: PluginWorkerLogSink + readyTimeoutMs?: number + invokeTimeoutMs?: number + eventTimeoutMs?: number + signal?: AbortSignal +} + +/** + * Resolves the compiled child entry from the app path. Mirrors + * getDaemonEntryPath(): packaged apps must fork the asar-unpacked copy + * because fork() cannot execute scripts from inside app.asar. + */ +export function resolvePluginHostEntryPath(appPath: string, isPackaged: boolean): string { + const basePath = isPackaged ? appPath.replace('app.asar', 'app.asar.unpacked') : appPath + const directEntryPath = join(basePath, 'plugin-host-entry.js') + if (existsSync(directEntryPath)) { + return directEntryPath + } + return join(basePath, 'out', 'main', 'plugin-host-entry.js') +} + +type PendingCall = { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType +} + +export async function startPluginWorker( + options: StartPluginWorkerOptions +): Promise { + const { pluginId, rootDir, mainEntry, entryPath, log } = options + const readyTimeoutMs = options.readyTimeoutMs ?? PLUGIN_WORKER_READY_TIMEOUT_MS + const invokeTimeoutMs = options.invokeTimeoutMs ?? PLUGIN_WORKER_INVOKE_TIMEOUT_MS + const eventTimeoutMs = options.eventTimeoutMs ?? PLUGIN_WORKER_EVENT_TIMEOUT_MS + const tag = `[plugin:${pluginId}]` + + const child: ChildProcess = fork(entryPath, [], { + // Why: ELECTRON_RUN_AS_NODE makes the forked Electron binary behave as + // plain Node. The env is a scrubbed allowlist — never ...process.env, + // which can carry shell-exported secrets into third-party code. + env: buildPluginWorkerEnv(), + // Why: inspector/loader flags from Orca's own launch must never execute + // inside third-party plugin workers. + execArgv: [], + // Why: the protocol permits structured-clone values. Node's default JSON + // fork serialization rejects BigInt, cycles, maps, and typed arrays. + serialization: 'advanced', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'] + }) + pipePluginWorkerOutput(child.stdout, 'info', log) + pipePluginWorkerOutput(child.stderr, 'error', log) + + const pendingCommands = new Map() + const pendingEvents = new Map>() + const exitCallbacks: ((code: number | null) => void)[] = [] + let nextCallId = 0 + let nextEventId = 0 + let exited = false + let exitCode: number | null = null + let disposed = false + let lastActivityAt = Date.now() + + function sendToChild(message: PluginWorkerParentMessage): void { + if (child.connected) { + child.send(message) + } + } + + function rejectAllPending(reason: string): void { + for (const [callId, entry] of pendingCommands) { + clearTimeout(entry.timer) + pendingCommands.delete(callId) + entry.reject(new Error(reason)) + } + for (const timer of pendingEvents.values()) { + clearTimeout(timer) + } + pendingEvents.clear() + } + + child.on('exit', (code) => { + exited = true + exitCode = code + rejectAllPending(`${tag} worker exited before responding`) + for (const callback of exitCallbacks) { + callback(code) + } + }) + child.on('disconnect', () => { + // Why: a worker can drop fork IPC while its event loop stays alive. Kill + // it so the ensuing exit enters the normal supervision/backoff path. + rejectAllPending(`${tag} worker disconnected before responding`) + if (!exited) { + child.kill('SIGKILL') + } + }) + + const commands = await new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + fail(new Error(`${tag} worker did not become ready within ${readyTimeoutMs}ms`)) + child.kill('SIGKILL') + }, readyTimeoutMs) + function fail(error: Error): void { + if (!settled) { + settled = true + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + reject(error) + } + } + const onAbort = (): void => { + fail(new Error(`${tag} worker startup was cancelled`)) + child.kill('SIGKILL') + } + options.signal?.addEventListener('abort', onAbort, { once: true }) + child.on('error', (error) => { + const failure = new Error(`${tag} worker process error: ${error.message}`) + fail(failure) + child.kill('SIGKILL') + // Why: fail() no-ops once ready; a post-ready channel fault must still + // reject in-flight calls instead of letting each hit its own timeout. + rejectAllPending(failure.message) + }) + child.on('exit', (code) => fail(new Error(`${tag} worker exited before ready (code ${code})`))) + child.on('message', (raw) => { + const parsed = pluginWorkerChildMessageSchema.safeParse(raw) + if (!parsed.success) { + log('warn', 'ignoring malformed worker message') + return + } + const message = parsed.data + switch (message.type) { + case 'ready': { + if (!settled) { + settled = true + clearTimeout(timer) + options.signal?.removeEventListener('abort', onAbort) + resolve(message.commands) + } + return + } + case 'commandResult': { + const entry = pendingCommands.get(message.callId) + if (!entry) { + return + } + clearTimeout(entry.timer) + pendingCommands.delete(message.callId) + lastActivityAt = Date.now() + if (message.ok) { + entry.resolve(message.value) + } else { + entry.reject(new Error(message.error ?? 'plugin command failed')) + } + return + } + case 'eventAck': { + const timer = pendingEvents.get(message.eventId) + if (timer) { + clearTimeout(timer) + pendingEvents.delete(message.eventId) + } + lastActivityAt = Date.now() + return + } + case 'hostCall': { + lastActivityAt = Date.now() + // Host API calls from the worker: gate + execute in main, then + // relay the outcome. Never throws — errors become outcomes. + void options.executeHostCall(message.method, message.params).then((outcome) => { + lastActivityAt = Date.now() + sendToChild( + outcome.ok + ? { type: 'hostResult', callId: message.callId, ok: true, value: outcome.value } + : { + type: 'hostResult', + callId: message.callId, + ok: false, + errorCode: outcome.code, + error: outcome.error + } + ) + }) + return + } + case 'log': { + log(message.level, message.message) + return + } + case 'fatal': { + fail(new Error(`${tag} worker crashed: ${message.error}`)) + rejectAllPending(`${tag} worker crashed: ${message.error}`) + child.kill('SIGKILL') + } + } + }) + sendToChild({ + type: 'init', + pluginId, + pluginRoot: rootDir, + mainEntry, + grantedCapabilities: [...options.grantedCapabilities] + }) + if (options.signal?.aborted) { + onAbort() + } + }) + + return { + commands, + invokeCommand(commandId, args) { + if (exited || disposed) { + return Promise.reject(new Error(`${tag} worker is not running`)) + } + const callId = nextCallId++ + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingCommands.delete(callId) + reject(new Error(`${tag} ${commandId} timed out after ${invokeTimeoutMs}ms`)) + }, invokeTimeoutMs) + pendingCommands.set(callId, { resolve, reject, timer }) + sendToChild({ type: 'invokeCommand', callId, commandId, args }) + }) + }, + deliverEvent(event, payload) { + if (exited || disposed) { + return + } + if (pendingEvents.size >= PLUGIN_WORKER_MAX_PENDING_EVENTS) { + log('error', `${tag} exceeded the pending event limit`) + child.kill('SIGKILL') + return + } + lastActivityAt = Date.now() + const eventId = nextEventId++ + const timer = setTimeout(() => { + pendingEvents.delete(eventId) + log('error', `${tag} ${event} did not finish within ${eventTimeoutMs}ms`) + child.kill('SIGKILL') + }, eventTimeoutMs) + pendingEvents.set(eventId, timer) + sendToChild({ type: 'deliverEvent', eventId, event, payload }) + }, + lastActivityAt: () => lastActivityAt, + inFlightCount: () => pendingCommands.size + pendingEvents.size, + async dispose() { + if (disposed) { + return + } + disposed = true + if (exited) { + return + } + sendToChild({ type: 'shutdown' }) + await new Promise((resolve) => { + const killTimer = setTimeout(() => { + child.kill('SIGKILL') + }, PLUGIN_WORKER_SHUTDOWN_GRACE_MS) + child.once('exit', () => { + clearTimeout(killTimer) + resolve() + }) + if (exited) { + clearTimeout(killTimer) + resolve() + } + }) + }, + kill() { + child.kill('SIGKILL') + }, + onExit(callback) { + if (exited) { + callback(exitCode) + } else { + exitCallbacks.push(callback) + } + } + } +} diff --git a/src/main/plugins/plugin-host-runtime.test.ts b/src/main/plugins/plugin-host-runtime.test.ts new file mode 100644 index 00000000000..48d680a0382 --- /dev/null +++ b/src/main/plugins/plugin-host-runtime.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { createPluginWorkerRuntime } from './plugin-host-runtime' + +describe('plugin worker shutdown', () => { + it('normalizes either manifest separator before importing the worker', async () => { + const importModule = vi.fn(async () => ({ default: vi.fn() })) + const runtime = createPluginWorkerRuntime({ send: vi.fn(), importModule }) + + await runtime.handleMessage({ + type: 'init', + pluginId: 'orca-samples.demo', + pluginRoot: join('plugin-root'), + mainEntry: 'nested\\worker.js', + grantedCapabilities: [] + }) + + expect(importModule).toHaveBeenCalledWith( + pathToFileURL(join('plugin-root', 'nested', 'worker.js')).href + ) + }) + + it('awaits an optional deactivate export before exiting', async () => { + let finishDeactivate!: () => void + const deactivate = vi.fn( + () => + new Promise((resolve) => { + finishDeactivate = resolve + }) + ) + const send = vi.fn() + const exit = vi.fn() + const runtime = createPluginWorkerRuntime({ + send, + exit, + importModule: async () => ({ default: vi.fn(), deactivate }) + }) + await runtime.handleMessage({ + type: 'init', + pluginId: 'orca-samples.demo', + pluginRoot: '/plugin', + mainEntry: 'worker.js', + grantedCapabilities: [] + }) + + const shutdown = runtime.handleMessage({ type: 'shutdown' }) + await Promise.resolve() + expect(deactivate).toHaveBeenCalledOnce() + expect(exit).not.toHaveBeenCalled() + finishDeactivate() + await shutdown + + expect(exit).toHaveBeenCalledWith(0) + }) + + it('exits immediately when the plugin has no deactivate export', async () => { + const exit = vi.fn() + const runtime = createPluginWorkerRuntime({ + send: vi.fn(), + exit, + importModule: async () => ({ default: vi.fn() }) + }) + await runtime.handleMessage({ + type: 'init', + pluginId: 'orca-samples.demo', + pluginRoot: '/plugin', + mainEntry: 'worker.js', + grantedCapabilities: [] + }) + + await runtime.handleMessage({ type: 'shutdown' }) + + expect(exit).toHaveBeenCalledWith(0) + }) +}) diff --git a/src/main/plugins/plugin-host-runtime.ts b/src/main/plugins/plugin-host-runtime.ts new file mode 100644 index 00000000000..2a2f5f9fcab --- /dev/null +++ b/src/main/plugins/plugin-host-runtime.ts @@ -0,0 +1,210 @@ +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + pluginWorkerParentMessageSchema, + type PluginWorkerChildMessage +} from '../../shared/plugins/plugin-host-protocol' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' + +/** + * Message-loop core of the out-of-process plugin worker. Electron-free and + * side-effect-free (send/import/exit are injected) so it unit-tests without + * forking a real child process; `plugin-host-entry.ts` wires it to the fork + * IPC channel. + */ + +export type PluginHostCallError = Error & { code?: string } + +/** API surface handed to a plugin's `activate(orca)` export. Everything is + * EXPERIMENTAL until pluginApi v1 freezes. */ +export type PluginWorkerOrcaApi = { + /** Register the handler for a command declared in the manifest. */ + commands: { + register(commandId: string, handler: (args: unknown) => unknown | Promise): void + } + /** Handle an event the manifest subscribed to (`contributes.events`). */ + events: { + on(event: PluginEventName, handler: (payload: unknown) => void | Promise): void + } + /** Call a host API method (capability-gated host-side). */ + host: { + call(method: string, params?: unknown): Promise + } + /** Consented capability kinds (informational — the host re-gates). */ + grantedCapabilities: readonly string[] + log(message: string): void +} + +export type PluginWorkerRuntimeOptions = { + send: (message: PluginWorkerChildMessage) => void + importModule?: (specifier: string) => Promise + exit?: (code: number) => void +} + +export type PluginWorkerRuntime = { + handleMessage(raw: unknown): Promise +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? (error.stack ?? error.message) : String(error) +} + +export function createPluginWorkerRuntime( + options: PluginWorkerRuntimeOptions +): PluginWorkerRuntime { + const send = options.send + const importModule = options.importModule ?? ((specifier: string) => import(specifier)) + const exit = options.exit ?? ((code: number) => process.exit(code)) + const commandHandlers = new Map unknown | Promise>() + const eventHandlers = new Map void | Promise)[]>() + const pendingHostCalls = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: PluginHostCallError) => void } + >() + let nextHostCallId = 0 + let initialized = false + let shuttingDown = false + let deactivate: (() => unknown | Promise) | null = null + + async function handleInit(input: { + pluginRoot: string + mainEntry: string + grantedCapabilities: string[] + }): Promise { + if (initialized) { + send({ type: 'log', level: 'warn', message: 'ignoring duplicate init message' }) + return + } + initialized = true + // Why: file URL import keeps ESM plugin entries working on Windows paths. + // Why: manifest paths accept either portable separator; split explicitly + // so a Windows-authored plugin also imports on macOS/Linux and vice versa. + const entryUrl = pathToFileURL(join(input.pluginRoot, ...input.mainEntry.split(/[\\/]/))).href + const module = (await importModule(entryUrl)) as { default?: unknown; deactivate?: unknown } + const activate = module?.default + if (typeof activate !== 'function') { + throw new Error(`plugin entry ${input.mainEntry} has no default-exported activate function`) + } + if (module.deactivate !== undefined && typeof module.deactivate !== 'function') { + throw new Error(`plugin entry ${input.mainEntry} has a non-function deactivate export`) + } + deactivate = (module.deactivate as (() => unknown | Promise) | undefined) ?? null + const orca: PluginWorkerOrcaApi = { + commands: { + register(commandId, handler) { + commandHandlers.set(commandId, handler) + } + }, + events: { + on(event, handler) { + const handlers = eventHandlers.get(event) ?? [] + handlers.push(handler) + eventHandlers.set(event, handlers) + } + }, + host: { + call(method, params) { + const callId = nextHostCallId++ + return new Promise((resolve, reject) => { + pendingHostCalls.set(callId, { resolve, reject }) + send({ type: 'hostCall', callId, method, params }) + }) + } + }, + grantedCapabilities: input.grantedCapabilities, + log(message) { + send({ type: 'log', level: 'info', message: String(message).slice(0, 8192) }) + } + } + await activate(orca) + send({ type: 'ready', commands: [...commandHandlers.keys()] }) + } + + return { + async handleMessage(raw) { + const parsed = pluginWorkerParentMessageSchema.safeParse(raw) + if (!parsed.success) { + send({ type: 'log', level: 'warn', message: 'ignoring malformed parent message' }) + return + } + const message = parsed.data + try { + switch (message.type) { + case 'init': { + await handleInit(message) + return + } + case 'invokeCommand': { + const handler = commandHandlers.get(message.commandId) + if (!handler) { + send({ + type: 'commandResult', + callId: message.callId, + ok: false, + error: `no handler registered for command ${message.commandId}` + }) + return + } + try { + const value = await handler(message.args) + send({ type: 'commandResult', callId: message.callId, ok: true, value }) + } catch (error) { + send({ + type: 'commandResult', + callId: message.callId, + ok: false, + error: toErrorMessage(error) + }) + } + return + } + case 'deliverEvent': { + const handlers = eventHandlers.get(message.event) ?? [] + for (const handler of handlers) { + try { + await handler(message.payload) + } catch (error) { + send({ type: 'log', level: 'error', message: toErrorMessage(error) }) + } + } + send({ type: 'eventAck', eventId: message.eventId }) + return + } + case 'hostResult': { + const pending = pendingHostCalls.get(message.callId) + if (!pending) { + return + } + pendingHostCalls.delete(message.callId) + if (message.ok) { + pending.resolve(message.value) + } else { + const error: PluginHostCallError = new Error(message.error ?? 'host call failed') + error.code = message.errorCode + pending.reject(error) + } + return + } + case 'shutdown': { + if (shuttingDown) { + return + } + shuttingDown = true + try { + await deactivate?.() + } catch (error) { + send({ type: 'log', level: 'error', message: toErrorMessage(error).slice(0, 8192) }) + } + exit(0) + } + } + } catch (error) { + // Why: an init/activation failure leaves the worker useless; report + // and die so the parent surfaces the error instead of hanging on + // the ready timeout. + send({ type: 'fatal', error: toErrorMessage(error) }) + exit(1) + } + } + } +} diff --git a/src/main/plugins/plugin-host-service-bindings.ts b/src/main/plugins/plugin-host-service-bindings.ts new file mode 100644 index 00000000000..a9aad46d387 --- /dev/null +++ b/src/main/plugins/plugin-host-service-bindings.ts @@ -0,0 +1,84 @@ +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { PLUGIN_WORKSPACE_TERMINAL_LIMIT } from '../../shared/plugins/plugin-host-api' +import type { PluginHostServices } from './plugin-host-methods' +import { PluginSecretsStore } from './plugin-secrets-store' +import { PluginKvStore } from './plugin-storage-store' + +/** Structural subset of OrcaRuntimeService exposed to plugin facade bindings. */ +export type PluginRuntimeDelegate = { + resolveActiveWorktreeContext(): Promise<{ + worktreeId: string + path: string + branch: string + displayName: string + } | null> + listTerminals( + worktreeSelector?: string, + limit?: number + ): Promise<{ terminals: { handle: string; title: string | null }[] }> + sendTerminal( + handle: string, + action: { text?: string; enter?: boolean } + ): Promise<{ accepted: boolean }> + dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> +} + +export function bindPluginHostServices(input: { + delegate: PluginRuntimeDelegate + pluginsDataDir: string + subscribeEvents: (pluginKey: string, events: PluginEventName[]) => PluginEventName[] +}): PluginHostServices { + const { delegate, pluginsDataDir, subscribeEvents } = input + return { + resolveActiveWorktreeContext: async () => { + const context = await delegate.resolveActiveWorktreeContext() + if (!context) { + return null + } + // Why: retain the internal id only for host-side terminal membership; + // the public handler projects it out because it embeds provider paths. + return { + worktreeId: context.worktreeId, + branch: context.branch, + displayName: context.displayName + } + }, + listWorktreeTerminals: async (worktreeId) => { + const result = await delegate.listTerminals( + `id:${worktreeId}`, + PLUGIN_WORKSPACE_TERMINAL_LIMIT + ) + return result.terminals + .slice(0, PLUGIN_WORKSPACE_TERMINAL_LIMIT) + .map((terminal) => ({ id: terminal.handle })) + }, + sendTerminalText: async (terminalId, action) => { + const result = await delegate.sendTerminal(terminalId, action) + return { accepted: result.accepted } + }, + dispatchPluginNotification: (notification) => delegate.dispatchPluginNotification(notification), + storage: { + get: (key, itemKey) => new PluginKvStore(pluginsDataDir, key, 'storage.json').get(itemKey), + set: (key, itemKey, value) => + new PluginKvStore(pluginsDataDir, key, 'storage.json').set(itemKey, value), + delete: (key, itemKey) => + new PluginKvStore(pluginsDataDir, key, 'storage.json').delete(itemKey), + keys: (key) => new PluginKvStore(pluginsDataDir, key, 'storage.json').keys() + }, + secrets: { + get: (key, itemKey) => new PluginSecretsStore(pluginsDataDir, key).get(itemKey), + set: (key, itemKey, value) => new PluginSecretsStore(pluginsDataDir, key).set(itemKey, value), + delete: (key, itemKey) => new PluginSecretsStore(pluginsDataDir, key).delete(itemKey) + }, + settings: { + getAll: (key) => new PluginKvStore(pluginsDataDir, key, 'settings.json').getAll(), + set: (key, itemKey, value) => + new PluginKvStore(pluginsDataDir, key, 'settings.json').set(itemKey, value) + }, + subscribeEvents + } +} diff --git a/src/main/plugins/plugin-install-lockfile-store.ts b/src/main/plugins/plugin-install-lockfile-store.ts new file mode 100644 index 00000000000..b83079c1f0f --- /dev/null +++ b/src/main/plugins/plugin-install-lockfile-store.ts @@ -0,0 +1,83 @@ +import { createReadStream } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { + emptyPluginLockfile, + parsePluginLockfile, + serializePluginLockfile, + type PluginLockfile +} from '../../shared/plugins/plugin-install-lockfile' +import { writePluginFileAtomically } from './plugin-atomic-file-write' +import { recoverPluginLockfile } from './plugin-install-provenance' + +export const PLUGIN_LOCKFILE_MAX_BYTES = 5 * 1024 * 1024 +const lockfileAccessChains = new Map>() + +export function pluginLockfilePath(pluginsDir: string): string { + return join(pluginsDir, 'plugins.lock.json') +} + +async function serializeLockfileAccess( + pluginsDir: string, + operation: () => Promise +): Promise { + const previous = lockfileAccessChains.get(pluginsDir) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(operation) + const settled = run.then( + () => undefined, + () => undefined + ) + lockfileAccessChains.set(pluginsDir, settled) + try { + return await run + } finally { + if (lockfileAccessChains.get(pluginsDir) === settled) { + lockfileAccessChains.delete(pluginsDir) + } + } +} + +/** Reads the install index through a byte cap so a corrupt local file cannot + * turn every plugin-list refresh into an unbounded main-process allocation. */ +export async function readPluginLockfile(pluginsDir: string): Promise { + return serializeLockfileAccess(pluginsDir, () => readPluginLockfileUnserialized(pluginsDir)) +} + +async function readPluginLockfileUnserialized(pluginsDir: string): Promise { + let lock = emptyPluginLockfile() + try { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(pluginLockfilePath(pluginsDir))) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_LOCKFILE_MAX_BYTES) { + throw new Error('plugin lockfile exceeds its size limit') + } + chunks.push(bytes) + } + lock = parsePluginLockfile(JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8'))) + } catch { + // Missing/corrupt global indexes can be reconstructed from current-version provenance. + } + const recovered = await recoverPluginLockfile(pluginsDir, lock) + if (recovered.changed) { + await writePluginLockfileUnserialized(pluginsDir, recovered.lock).catch(() => undefined) + } + return recovered.lock +} + +export async function writePluginLockfile(pluginsDir: string, lock: PluginLockfile): Promise { + await serializeLockfileAccess(pluginsDir, () => writePluginLockfileUnserialized(pluginsDir, lock)) +} + +async function writePluginLockfileUnserialized( + pluginsDir: string, + lock: PluginLockfile +): Promise { + await mkdir(pluginsDir, { recursive: true }) + await writePluginFileAtomically( + pluginLockfilePath(pluginsDir), + JSON.stringify(serializePluginLockfile(lock), null, 2) + ) +} diff --git a/src/main/plugins/plugin-install-provenance.ts b/src/main/plugins/plugin-install-provenance.ts new file mode 100644 index 00000000000..76f06c318fe --- /dev/null +++ b/src/main/plugins/plugin-install-provenance.ts @@ -0,0 +1,118 @@ +import { createReadStream } from 'node:fs' +import { mkdir, readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { + PLUGIN_CONTENT_HASH_PATTERN, + pluginLockEntrySchema, + type PluginLockEntry, + type PluginLockfile +} from '../../shared/plugins/plugin-install-lockfile' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { writePluginFileAtomically } from './plugin-atomic-file-write' +import { readPluginCurrentPointer } from './plugin-current-pointer' + +const PROVENANCE_DIRECTORY = '.install-provenance' +const PROVENANCE_MAX_BYTES = 64 * 1024 + +function provenancePath(pluginDir: string, contentHash: string): string { + if (!PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) { + throw new Error('invalid plugin content hash') + } + return join(pluginDir, PROVENANCE_DIRECTORY, `${contentHash}.json`) +} + +/** Prewrites immutable provenance before the executable current pointer moves. */ +export async function writePluginInstallProvenance( + pluginDir: string, + entry: PluginLockEntry +): Promise { + const parsedEntry = pluginLockEntrySchema.parse(entry) + const directory = join(pluginDir, PROVENANCE_DIRECTORY) + await mkdir(directory, { recursive: true, mode: 0o700 }) + await writePluginFileAtomically( + provenancePath(pluginDir, parsedEntry.contentHash), + JSON.stringify({ version: 1, entry: parsedEntry }, null, 2), + { mode: 0o600 } + ) +} + +export async function readPluginInstallProvenance( + pluginDir: string, + contentHash: string +): Promise { + try { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(provenancePath(pluginDir, contentHash))) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PROVENANCE_MAX_BYTES) { + return null + } + chunks.push(bytes) + } + const raw = JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) as { + version?: unknown + entry?: unknown + } + if (raw.version !== 1) { + return null + } + const parsed = pluginLockEntrySchema.safeParse(raw.entry) + return parsed.success ? parsed.data : null + } catch { + return null + } +} + +/** Repairs a pointer-new/lock-old interrupted publication from immutable provenance. */ +export async function recoverPluginLockfile( + pluginsDir: string, + lock: PluginLockfile +): Promise<{ lock: PluginLockfile; changed: boolean }> { + const plugins = { ...lock.plugins } + let changed = false + const directories = await readdir(pluginsDir, { withFileTypes: true }).catch(() => []) + for (const directory of directories) { + if (!directory.isDirectory() || !isQualifiedPluginKey(directory.name)) { + continue + } + const pluginDir = join(pluginsDir, directory.name) + const contentHash = await readPluginCurrentPointer(pluginDir).catch(() => null) + if (!contentHash || !PLUGIN_CONTENT_HASH_PATTERN.test(contentHash)) { + continue + } + const provenance = await readPluginInstallProvenance(pluginDir, contentHash) + if ( + !provenance || + provenance.pluginKey !== directory.name || + provenance.contentHash !== contentHash + ) { + continue + } + if (JSON.stringify(plugins[directory.name]) !== JSON.stringify(provenance)) { + plugins[directory.name] = provenance + changed = true + } + } + return { lock: { version: 1, plugins }, changed } +} + +export async function prunePluginInstallProvenance( + pluginDir: string, + retained: ReadonlySet +): Promise { + const directory = join(pluginDir, PROVENANCE_DIRECTORY) + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []) + await Promise.all( + entries + .filter( + (entry) => + entry.isFile() && + entry.name.endsWith('.json') && + PLUGIN_CONTENT_HASH_PATTERN.test(entry.name.slice(0, -'.json'.length)) && + !retained.has(entry.name.slice(0, -'.json'.length)) + ) + .map((entry) => rm(join(directory, entry.name), { force: true })) + ) +} diff --git a/src/main/plugins/plugin-install-publication.ts b/src/main/plugins/plugin-install-publication.ts new file mode 100644 index 00000000000..291afa76fc7 --- /dev/null +++ b/src/main/plugins/plugin-install-publication.ts @@ -0,0 +1,94 @@ +import { readdir, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { + PLUGIN_CONTENT_HASH_PATTERN, + upsertPluginLock, + type PluginLockEntry +} from '../../shared/plugins/plugin-install-lockfile' +import { + readPluginCurrentPointer, + restorePluginCurrentPointer, + writePluginCurrentPointer +} from './plugin-current-pointer' +import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' +import { + prunePluginInstallProvenance, + readPluginInstallProvenance, + writePluginInstallProvenance +} from './plugin-install-provenance' + +/** Publishes executable identity and provenance as one recoverable mutation, + * then retains only current plus one rollback version. */ +export async function publishPluginInstall(input: { + pluginsDir: string + pluginDir: string + entry: PluginLockEntry +}): Promise { + const previousContentHash = await readPluginCurrentPointer(input.pluginDir) + const currentLock = await readPluginLockfile(input.pluginsDir) + const provenanceCandidate = + previousContentHash === input.entry.contentHash + ? await readPluginInstallProvenance(input.pluginDir, input.entry.contentHash) + : null + const matchesCurrentIdentity = (entry: PluginLockEntry | undefined | null): boolean => + entry?.pluginKey === input.entry.pluginKey && entry.contentHash === input.entry.contentHash + const existingProvenance = matchesCurrentIdentity(provenanceCandidate) + ? provenanceCandidate + : null + const legacyLockEntry = currentLock.plugins[input.entry.pluginKey] + const legacyCurrentEntry = + previousContentHash === input.entry.contentHash && matchesCurrentIdentity(legacyLockEntry) + ? legacyLockEntry + : null + // Provenance is immutable per executable identity. A same-byte reinstall + // is a no-op so a failed or interrupted source change cannot be recovered + // later as though it had successfully published. + const publishedEntry = existingProvenance ?? legacyCurrentEntry ?? input.entry + const nextLock = upsertPluginLock(currentLock, publishedEntry) + // Why: after a crash between pointer and global-index publication, startup + // can reconstruct exact source/commit identity from this immutable record. + if (!existingProvenance) { + await writePluginInstallProvenance(input.pluginDir, publishedEntry) + } + await writePluginCurrentPointer(input.pluginDir, input.entry.contentHash) + try { + await writePluginLockfile(input.pluginsDir, nextLock) + } catch (publicationError) { + try { + await restorePluginCurrentPointer(input.pluginDir, previousContentHash) + } catch (rollbackError) { + throw new AggregateError( + [publicationError, rollbackError], + 'plugin install publication and pointer rollback both failed' + ) + } + throw publicationError + } + // Reinstalling B must not collapse an existing A rollback into {B}. + if (previousContentHash !== input.entry.contentHash) { + await pruneHistoricalVersions( + input.pluginDir, + new Set( + [input.entry.contentHash, previousContentHash].filter( + (hash): hash is string => + typeof hash === 'string' && PLUGIN_CONTENT_HASH_PATTERN.test(hash) + ) + ) + ).catch(() => undefined) + } +} + +async function pruneHistoricalVersions(pluginDir: string, retained: ReadonlySet) { + const entries = await readdir(pluginDir, { withFileTypes: true }) + await Promise.all( + entries + .filter( + (entry) => + entry.isDirectory() && + PLUGIN_CONTENT_HASH_PATTERN.test(entry.name) && + !retained.has(entry.name) + ) + .map((entry) => rm(join(pluginDir, entry.name), { recursive: true, force: true })) + ) + await prunePluginInstallProvenance(pluginDir, retained) +} diff --git a/src/main/plugins/plugin-install-staging.ts b/src/main/plugins/plugin-install-staging.ts new file mode 100644 index 00000000000..2f0a71fe7a0 --- /dev/null +++ b/src/main/plugins/plugin-install-staging.ts @@ -0,0 +1,253 @@ +import { existsSync } from 'node:fs' +import { cp, mkdir, rm } from 'node:fs/promises' +import { join, relative, resolve, sep } from 'node:path' +import { + PLUGIN_MANIFEST_FILENAME, + parsePluginManifest, + qualifiedPluginKey, + satisfiesOrcaEngineRange, + type PluginManifest +} from '../../shared/plugins/plugin-manifest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import type { + PluginInstallSource, + PluginLockEntry +} from '../../shared/plugins/plugin-install-lockfile' +import { + validateDeclaredPluginArtifacts, + validatePluginInstallContent, + type PluginArtifactValidationResult +} from './plugin-artifact-validation' +import { renamePluginFileWithWindowsRetry } from './plugin-atomic-file-write' +import { hashPluginTree } from './plugin-content-hash' +import { publishPluginInstall } from './plugin-install-publication' +import { readPluginManifestText } from './plugin-manifest-file' +import { pluginInstallTrustError } from './plugin-install-trust' + +export type PluginInstallResult = + | { + ok: true + pluginKey: string + version: string + contentHash: string + consentFingerprint: string + resolvedCommit: string | null + } + | { ok: false; error: string } + +export type PluginInstallInspection = + | { + ok: true + manifest: PluginManifest + pluginKey: string + contentHash: string + consentFingerprint: string + } + | { ok: false; error: string } + +async function validatePluginInstallTree( + rootDir: string, + manifest: PluginManifest +): Promise { + const declared = await validateDeclaredPluginArtifacts(rootDir, manifest) + return declared.ok ? validatePluginInstallContent(rootDir, manifest) : declared +} + +async function readInstallManifest( + rootDir: string, + hostVersion: string +): Promise<{ ok: true; manifest: PluginManifest } | { ok: false; error: string }> { + let raw: unknown + try { + raw = JSON.parse(await readPluginManifestText(rootDir)) + } catch (error) { + return { + ok: false, + error: `unreadable ${PLUGIN_MANIFEST_FILENAME}: ${error instanceof Error ? error.message : String(error)}` + } + } + const parsed = parsePluginManifest(raw) + if (!parsed.ok) { + return { ok: false, error: `invalid manifest: ${parsed.error}` } + } + if (!satisfiesOrcaEngineRange(hostVersion, parsed.manifest.engines.orca)) { + return { + ok: false, + error: `plugin requires Orca ${parsed.manifest.engines.orca} (this is ${hostVersion})` + } + } + return { ok: true, manifest: parsed.manifest } +} + +/** Validates and hashes a source tree without publishing it. Marketplace + * previews use this exact path so the reviewed bytes match install policy. */ +export async function inspectPluginInstallTree(input: { + rootDir: string + hostVersion: string + expectedPluginKey?: string +}): Promise { + const sourceManifest = await readInstallManifest(input.rootDir, input.hostVersion) + if (!sourceManifest.ok) { + return sourceManifest + } + const pluginKey = qualifiedPluginKey(sourceManifest.manifest) + if (input.expectedPluginKey && pluginKey !== input.expectedPluginKey) { + return { + ok: false, + error: `plugin manifest identity ${pluginKey} does not match marketplace listing ${input.expectedPluginKey}` + } + } + const declaredArtifacts = await validatePluginInstallTree(input.rootDir, sourceManifest.manifest) + if (!declaredArtifacts.ok) { + return { ok: false, error: `invalid declared artifact: ${declaredArtifacts.error}` } + } + const treeHash = await hashPluginTree(input.rootDir) + if (!treeHash.ok) { + return { ok: false, error: treeHash.error } + } + return { + ok: true, + manifest: sourceManifest.manifest, + pluginKey, + contentHash: treeHash.hash, + consentFingerprint: fingerprintPluginConsent(sourceManifest.manifest, treeHash.hash) + } +} + +/** Installs a validated staging tree into the hash-addressed layout. */ +export async function installStagedPluginTree(input: { + pluginsDir: string + stagingDir: string + hostVersion: string + source: PluginInstallSource + resolvedCommit: string | null + expectedPluginKey?: string + /** Trusted bundled bytes may restore an immutable directory damaged on disk. */ + repairCorruptedVersion?: boolean + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + const sourceInspection = await inspectPluginInstallTree({ + rootDir: input.stagingDir, + hostVersion: input.hostVersion, + ...(input.expectedPluginKey ? { expectedPluginKey: input.expectedPluginKey } : {}) + }) + if (!sourceInspection.ok) { + return sourceInspection + } + const trustError = pluginInstallTrustError(sourceInspection.pluginKey, input.source) + if (trustError) { + return { ok: false, error: trustError } + } + const blockedReason = input.blockedPluginReason?.(sourceInspection.pluginKey) + if (blockedReason) { + return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } + } + let manifest = sourceInspection.manifest + const pluginKey = sourceInspection.pluginKey + const pluginDir = join(input.pluginsDir, pluginKey) + const versionDir = join(pluginDir, sourceInspection.contentHash) + if (existsSync(versionDir) && input.repairCorruptedVersion) { + const existingHash = await hashPluginTree(versionDir) + if (!existingHash.ok || existingHash.hash !== sourceInspection.contentHash) { + // Why: bundled resources are release-index verified above, so they can + // safely restore a damaged immutable install instead of staying broken. + await rm(versionDir, { recursive: true, force: true }) + } + } + if (!existsSync(versionDir)) { + const stagedVersionDir = `${versionDir}.staging` + try { + await rm(stagedVersionDir, { recursive: true, force: true }) + await mkdir(pluginDir, { recursive: true }) + // Source trees can change while copying. Hashing the destination closes + // that race before the immutable directory becomes current. + const stagingRoot = resolve(input.stagingDir) + await cp(stagingRoot, stagedVersionDir, { + recursive: true, + verbatimSymlinks: true, + // Source-control metadata is not plugin content. Skip it at the copy + // boundary so a large local repository cannot bypass install limits. + filter: (source) => { + const fromRoot = relative(stagingRoot, resolve(source)) + return fromRoot !== '.git' && !fromRoot.startsWith(`.git${sep}`) + } + }) + const copiedHash = await hashPluginTree(stagedVersionDir) + if (!copiedHash.ok || copiedHash.hash !== sourceInspection.contentHash) { + return { + ok: false, + error: copiedHash.ok + ? 'plugin content changed while it was being copied' + : copiedHash.error + } + } + const copiedManifest = await readInstallManifest(stagedVersionDir, input.hostVersion) + if (!copiedManifest.ok) { + return { ok: false, error: `copied ${copiedManifest.error}` } + } + if (qualifiedPluginKey(copiedManifest.manifest) !== pluginKey) { + return { ok: false, error: 'plugin manifest identity changed while it was being staged' } + } + manifest = copiedManifest.manifest + const copiedArtifacts = await validatePluginInstallTree(stagedVersionDir, manifest) + if (!copiedArtifacts.ok) { + return { ok: false, error: `copied artifact validation failed: ${copiedArtifacts.error}` } + } + await renamePluginFileWithWindowsRetry(stagedVersionDir, versionDir) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + await rm(stagedVersionDir, { recursive: true, force: true }) + } + } else { + // Never repoint at an existing hash directory without proving its bytes; + // a previous partial/tampered install must not be revived by reinstall. + const existingHash = await hashPluginTree(versionDir) + if (!existingHash.ok || existingHash.hash !== sourceInspection.contentHash) { + return { + ok: false, + error: existingHash.ok + ? 'existing plugin content failed integrity verification' + : existingHash.error + } + } + const existingManifest = await readInstallManifest(versionDir, input.hostVersion) + if (!existingManifest.ok) { + return { ok: false, error: `installed ${existingManifest.error}` } + } + if (qualifiedPluginKey(existingManifest.manifest) !== pluginKey) { + return { ok: false, error: 'installed plugin manifest identity does not match its directory' } + } + manifest = existingManifest.manifest + const existingArtifacts = await validatePluginInstallTree(versionDir, manifest) + if (!existingArtifacts.ok) { + return { + ok: false, + error: `installed artifact validation failed: ${existingArtifacts.error}` + } + } + } + const consentFingerprint = fingerprintPluginConsent(manifest, sourceInspection.contentHash) + const entry: PluginLockEntry = { + pluginKey, + version: manifest.version, + source: input.source, + resolvedCommit: input.resolvedCommit, + contentHash: sourceInspection.contentHash, + consentFingerprint, + installedAt: Date.now() + } + try { + await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + return { + ok: true, + pluginKey, + version: manifest.version, + contentHash: sourceInspection.contentHash, + consentFingerprint, + resolvedCommit: input.resolvedCommit + } +} diff --git a/src/main/plugins/plugin-install-trust.test.ts b/src/main/plugins/plugin-install-trust.test.ts new file mode 100644 index 00000000000..8f1c5e302d9 --- /dev/null +++ b/src/main/plugins/plugin-install-trust.test.ts @@ -0,0 +1,115 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { PluginInstallSource } from '../../shared/plugins/plugin-install-lockfile' +import { + installBundledPlugin, + installPluginFromLocalPath, + readPluginLockfile +} from './plugin-install' +import { pluginInstallTrustError } from './plugin-install-trust' + +const roots: string[] = [] + +async function tempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + return root +} + +async function writePlugin(root: string, publisher: string, id: string): Promise { + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id, + publisher, + name: 'Plugin', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [] + }) + ) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('plugin install trust', () => { + it.each<[PluginInstallSource, string | null]>([ + [ + { + kind: 'git', + url: 'https://github.com/attacker/orca-secrets.git', + ref: 'main' + }, + 'reserved plugin identity community.orca-secrets must resolve to the stablyai organization' + ], + [ + { + kind: 'git', + url: 'git@github.com:stablyai/orca-secrets.git', + ref: 'main' + }, + null + ] + ])('enforces reserved source organization', (source, expected) => { + expect(pluginInstallTrustError('community.orca-secrets', source)).toBe(expected) + }) + + it('rejects locally installed reserved identities before publication', async () => { + const sourcePath = await tempRoot('orca-reserved-plugin-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePlugin(sourcePath, 'stablyai', 'orca-skills') + + await expect( + installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' }) + ).resolves.toEqual({ + ok: false, + error: 'reserved plugin identity stablyai.orca-skills cannot be installed from a local path' + }) + await expect(readPluginLockfile(pluginsDir)).resolves.toEqual({ version: 1, plugins: {} }) + }) + + it('allows the app-bundled path only for the complete official identity', async () => { + const sourcePath = await tempRoot('orca-bundled-plugin-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePlugin(sourcePath, 'stablyai', 'orca-skills') + + const result = await installBundledPlugin({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0', + expectedPluginKey: 'stablyai.orca-skills' + }) + + expect(result).toMatchObject({ ok: true, pluginKey: 'stablyai.orca-skills' }) + const lock = await readPluginLockfile(pluginsDir) + expect(lock.plugins['stablyai.orca-skills']?.source).toEqual({ + kind: 'bundled', + bundleId: 'stablyai.orca-skills' + }) + }) + + it('blocks a killed plugin even when the caller bypasses marketplace UI', async () => { + const sourcePath = await tempRoot('orca-killed-plugin-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePlugin(sourcePath, 'community', 'unsafe') + + await expect( + installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0', + blockedPluginReason: (pluginKey) => + pluginKey === 'community.unsafe' ? 'Security incident' : null + }) + ).resolves.toEqual({ + ok: false, + error: "plugin is blocked by Orca's safety list: Security incident" + }) + }) +}) diff --git a/src/main/plugins/plugin-install-trust.ts b/src/main/plugins/plugin-install-trust.ts new file mode 100644 index 00000000000..33b46614af6 --- /dev/null +++ b/src/main/plugins/plugin-install-trust.ts @@ -0,0 +1,27 @@ +import type { PluginInstallSource } from '../../shared/plugins/plugin-install-lockfile' +import { + isOfficialOrganizationGitSource, + isOfficialPluginIdentity, + isReservedPluginIdentity +} from '../../shared/plugins/plugin-marketplace' + +export function pluginInstallTrustError( + pluginKey: string, + source: PluginInstallSource +): string | null { + if (source.kind === 'bundled') { + return source.bundleId === pluginKey && isOfficialPluginIdentity(pluginKey) + ? null + : 'bundled plugins must use an official stablyai.orca-* identity' + } + if (!isReservedPluginIdentity(pluginKey)) { + return null + } + if (source.kind === 'local-path') { + return `reserved plugin identity ${pluginKey} cannot be installed from a local path` + } + const url = source.kind === 'git' ? source.url : source.plugin.url + return isOfficialOrganizationGitSource(url) + ? null + : `reserved plugin identity ${pluginKey} must resolve to the stablyai organization` +} diff --git a/src/main/plugins/plugin-install.test.ts b/src/main/plugins/plugin-install.test.ts new file mode 100644 index 00000000000..ab9591eba3d --- /dev/null +++ b/src/main/plugins/plugin-install.test.ts @@ -0,0 +1,531 @@ +import { execFile } from 'node:child_process' +import { + mkdtemp, + mkdir, + readFile, + readdir, + rm, + symlink, + truncate, + writeFile +} from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + installPluginFromLocalPath, + installPluginFromGit, + PLUGIN_LOCKFILE_MAX_BYTES, + readPluginLockfile, + removeInstalledPlugin +} from './plugin-install' +import { PLUGIN_MANIFEST_MAX_BYTES } from './plugin-manifest-file' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { writePluginLockfile } from './plugin-install-lockfile-store' +import { installStagedPluginTree } from './plugin-install-staging' +import * as manifestFile from './plugin-manifest-file' + +const roots: string[] = [] +const execFileAsync = promisify(execFile) + +async function tempRoot(prefix: string): Promise { + const root = await mkdtemp(join(tmpdir(), prefix)) + roots.push(root) + return root +} + +async function writePluginSource( + root: string, + options: { id?: string; panelEntry?: string; includePanel?: boolean } = {} +): Promise { + const panelEntry = options.panelEntry ?? 'panel.html' + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: options.id ?? 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: panelEntry }], + commands: [], + events: [] + }, + capabilities: [] + }) + ) + if (options.includePanel !== false) { + await writeFile(join(root, panelEntry), '

    Panel

    ') + } +} + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('installPluginFromLocalPath', () => { + it('refuses to allocate an oversized install lockfile', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const lockPath = join(pluginsDir, 'plugins.lock.json') + await writeFile(lockPath, '') + await truncate(lockPath, PLUGIN_LOCKFILE_MAX_BYTES + 1) + + await expect(readPluginLockfile(pluginsDir)).resolves.toEqual({ version: 1, plugins: {} }) + }) + + it('verifies copied content and writes a rollback-compatible consent field', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + await expect( + readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8') + ).resolves.toBe('

    Panel

    ') + const lock = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as { + plugins: Record> + } + expect(lock.plugins[result.pluginKey]).toMatchObject({ + capabilityHash: result.consentFingerprint + }) + expect(lock.plugins[result.pluginKey]).not.toHaveProperty('consentFingerprint') + }) + + it('publishes metadata from the copied immutable manifest', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const firstManifest = await readFile(join(sourcePath, 'orca-plugin.json'), 'utf8') + const changedManifest = { + ...(JSON.parse(firstManifest) as Record), + name: 'Changed During Staging', + version: '2.0.0' + } + await writeFile(join(sourcePath, 'orca-plugin.json'), JSON.stringify(changedManifest)) + const manifestRead = vi + .spyOn(manifestFile, 'readPluginManifestText') + .mockResolvedValueOnce(firstManifest) + + const result = await installStagedPluginTree({ + pluginsDir, + stagingDir: sourcePath, + hostVersion: '1.4.0', + source: { kind: 'local-path', path: sourcePath }, + resolvedCommit: null + }) + + expect(manifestRead).toHaveBeenCalledTimes(2) + expect(result).toMatchObject({ ok: true, version: '2.0.0' }) + if (result.ok) { + const lock = await readPluginLockfile(pluginsDir) + expect(lock.plugins[result.pluginKey]?.version).toBe('2.0.0') + } + }) + + it('skips root Git metadata before copying while still enforcing plugin limits', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const gitDir = join(sourcePath, '.git') + await mkdir(gitDir) + await writeFile(join(gitDir, 'large.pack'), '') + await truncate(join(gitDir, 'large.pack'), 50 * 1024 * 1024 + 1) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: true }) + if (result.ok) { + await expect( + readFile(join(pluginsDir, result.pluginKey, result.contentHash, '.git', 'large.pack')) + ).rejects.toMatchObject({ code: 'ENOENT' }) + } + }) + + it('restores the previous current pointer when lockfile publication fails', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const first = await installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + await writeFile(join(sourcePath, 'panel.html'), '

    Updated

    ') + await rm(join(pluginsDir, 'plugins.lock.json')) + await mkdir(join(pluginsDir, 'plugins.lock.json')) + + const failed = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(failed).toMatchObject({ ok: false }) + await expect(readPluginCurrentPointer(join(pluginsDir, first.pluginKey))).resolves.toBe( + first.contentHash + ) + }) + + it('does not replace provenance when a same-content reinstall fails to publish', async () => { + const firstSource = await tempRoot('orca-plugin-source-') + const secondSource = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(firstSource) + await writePluginSource(secondSource) + const first = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: firstSource, + hostVersion: '1.4.0' + }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + const acceptedLock = await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8') + await rm(join(pluginsDir, 'plugins.lock.json')) + await mkdir(join(pluginsDir, 'plugins.lock.json')) + + const failed = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: secondSource, + hostVersion: '1.4.0' + }) + expect(failed).toMatchObject({ ok: false }) + + await rm(join(pluginsDir, 'plugins.lock.json'), { recursive: true }) + await writeFile(join(pluginsDir, 'plugins.lock.json'), acceptedLock) + const recovered = await readPluginLockfile(pluginsDir) + expect(recovered.plugins[first.pluginKey]?.source).toEqual({ + kind: 'local-path', + path: firstSource + }) + }) + + it('preserves legacy lock provenance during a same-content reinstall', async () => { + const firstSource = await tempRoot('orca-plugin-source-') + const secondSource = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(firstSource) + await writePluginSource(secondSource) + const first = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: firstSource, + hostVersion: '1.4.0' + }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + await rm(join(pluginsDir, first.pluginKey, '.install-provenance', `${first.contentHash}.json`)) + const reinstalled = await installPluginFromLocalPath({ + pluginsDir, + sourcePath: secondSource, + hostVersion: '1.4.0' + }) + expect(reinstalled).toMatchObject({ ok: true }) + + // Recovery from the newly backfilled provenance must retain the accepted + // legacy source rather than the same-byte reinstall's alternate source. + await rm(join(pluginsDir, 'plugins.lock.json')) + const recovered = await readPluginLockfile(pluginsDir) + expect(recovered.plugins[first.pluginKey]?.source).toEqual({ + kind: 'local-path', + path: firstSource + }) + }) + + it('retains only the current and immediately previous content versions', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const hashes: string[] = [] + for (const content of ['one', 'two', 'three']) { + await writeFile(join(sourcePath, 'panel.html'), `

    ${content}

    `) + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(result.ok).toBe(true) + if (result.ok) { + hashes.push(result.contentHash) + } + } + + const versionDirs = ( + await readdir(join(pluginsDir, 'orca-samples.demo'), { + withFileTypes: true + }) + ) + .filter((entry) => entry.isDirectory() && /^[0-9a-f]{64}$/.test(entry.name)) + .map((entry) => entry.name) + .sort() + expect(versionDirs).toEqual(hashes.slice(-2).sort()) + }) + + it('keeps the rollback version when current content is reinstalled', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const hashes: string[] = [] + for (const content of ['one', 'two', 'two']) { + await writeFile(join(sourcePath, 'panel.html'), `

    ${content}

    `) + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(result.ok).toBe(true) + if (result.ok) { + hashes.push(result.contentHash) + } + } + + const versionDirs = ( + await readdir(join(pluginsDir, 'orca-samples.demo'), { withFileTypes: true }) + ) + .filter((entry) => entry.isDirectory() && /^[0-9a-f]{64}$/.test(entry.name)) + .map((entry) => entry.name) + .sort() + expect(versionDirs).toEqual([...new Set(hashes)].sort()) + }) + + it('repairs a pointer-new lock-old interrupted publication from provenance', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const first = await installPluginFromLocalPath({ pluginsDir, sourcePath, hostVersion: '1.4.0' }) + expect(first.ok).toBe(true) + const oldLock = await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8') + await writeFile(join(sourcePath, 'panel.html'), '

    new current

    ') + const second = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(second.ok).toBe(true) + if (!first.ok || !second.ok) { + return + } + + await writeFile(join(pluginsDir, 'plugins.lock.json'), oldLock) + const repaired = await readPluginLockfile(pluginsDir) + + expect(repaired.plugins[second.pluginKey]?.contentHash).toBe(second.contentHash) + const persisted = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as { + plugins: Record + } + expect(persisted.plugins[second.pluginKey]?.contentHash).toBe(second.contentHash) + }) + + it('rejects a manifest whose declared panel artifact is missing', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath, { includePanel: false }) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: false }) + }) + + it('rejects an oversized manifest without reading an unbounded JSON payload', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + const manifestPath = join(sourcePath, 'orca-plugin.json') + await writeFile(manifestPath, '') + await truncate(manifestPath, PLUGIN_MANIFEST_MAX_BYTES + 1) + + const result = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: false, error: expect.stringContaining('exceeds') }) + }) + + it('serializes concurrent installs so lockfile entries are not lost', async () => { + const firstSource = await tempRoot('orca-plugin-source-') + const secondSource = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(firstSource, { id: 'first' }) + await writePluginSource(secondSource, { id: 'second' }) + + const results = await Promise.all([ + installPluginFromLocalPath({ pluginsDir, sourcePath: firstSource, hostVersion: '1.4.0' }), + installPluginFromLocalPath({ pluginsDir, sourcePath: secondSource, hostVersion: '1.4.0' }) + ]) + expect(results.every((result) => result.ok)).toBe(true) + const lock = JSON.parse(await readFile(join(pluginsDir, 'plugins.lock.json'), 'utf8')) as { + plugins: Record + } + expect(Object.keys(lock.plugins).sort()).toEqual(['orca-samples.first', 'orca-samples.second']) + }) + + it('serializes concurrent lockfile publications without temporary-file collisions', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const lock = { version: 1 as const, plugins: {} } + + await expect( + Promise.all([ + writePluginLockfile(pluginsDir, lock), + writePluginLockfile(pluginsDir, lock), + writePluginLockfile(pluginsDir, lock) + ]) + ).resolves.toHaveLength(3) + await expect(readPluginLockfile(pluginsDir)).resolves.toEqual(lock) + }) + + it('refuses to repoint at a tampered existing content directory', async () => { + const sourcePath = await tempRoot('orca-plugin-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + const first = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + expect(first.ok).toBe(true) + if (!first.ok) { + return + } + await writeFile( + join(pluginsDir, first.pluginKey, first.contentHash, 'panel.html'), + '

    Tampered

    ' + ) + + const second = await installPluginFromLocalPath({ + pluginsDir, + sourcePath, + hostVersion: '1.4.0' + }) + + expect(second).toMatchObject({ + ok: false, + error: expect.stringContaining('integrity verification') + }) + }) +}) + +describe('installPluginFromGit', () => { + it('uses system Git, resolves the requested ref, and installs its exact bytes', async () => { + const sourcePath = await tempRoot('orca-plugin-git-source-') + const pluginsDir = await tempRoot('orca-plugin-installs-') + await writePluginSource(sourcePath) + await execFileAsync('git', ['init', '--quiet'], { cwd: sourcePath }) + await execFileAsync('git', ['config', 'user.email', 'plugins@example.invalid'], { + cwd: sourcePath + }) + await execFileAsync('git', ['config', 'user.name', 'Plugin Test'], { cwd: sourcePath }) + await execFileAsync('git', ['add', '.'], { cwd: sourcePath }) + await execFileAsync('git', ['commit', '--quiet', '-m', 'fixture'], { cwd: sourcePath }) + await execFileAsync('git', ['tag', 'v1.0.0'], { cwd: sourcePath }) + const { stdout: commitStdout } = await execFileAsync('git', ['rev-parse', 'HEAD'], { + cwd: sourcePath + }) + + const configKeys = ['GIT_CONFIG_COUNT', 'GIT_CONFIG_KEY_0', 'GIT_CONFIG_VALUE_0'] as const + const previous = Object.fromEntries(configKeys.map((key) => [key, process.env[key]])) + process.env.GIT_CONFIG_COUNT = '1' + process.env.GIT_CONFIG_KEY_0 = `url.${pathToFileURL(sourcePath).href}.insteadOf` + process.env.GIT_CONFIG_VALUE_0 = 'https://plugin.test/demo.git' + try { + const result = await installPluginFromGit({ + pluginsDir, + url: 'https://plugin.test/demo.git', + ref: 'v1.0.0', + hostVersion: '1.4.0' + }) + + expect(result).toMatchObject({ ok: true, resolvedCommit: commitStdout.trim() }) + if (result.ok) { + await expect( + readFile(join(pluginsDir, result.pluginKey, result.contentHash, 'panel.html'), 'utf8') + ).resolves.toBe('

    Panel

    ') + } + } finally { + for (const key of configKeys) { + const value = previous[key] + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + }) +}) + +describe('removeInstalledPlugin', () => { + it('rejects an unqualified or traversing key before removing anything', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const pluginsDataDir = await tempRoot('orca-plugin-data-') + const outside = join(await tempRoot('orca-plugin-outside-'), 'keep.txt') + await writeFile(outside, 'keep') + + await expect( + removeInstalledPlugin({ pluginsDir, pluginsDataDir, pluginKey: '../outside' }) + ).rejects.toThrow('invalid qualified plugin key') + await expect(readFile(outside, 'utf8')).resolves.toBe('keep') + }) + + it('rejects a resolved uninstall target outside its root', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const pluginsDataDir = await tempRoot('orca-plugin-data-') + const outside = await tempRoot('orca-plugin-outside-') + const marker = join(outside, 'keep.txt') + await writeFile(marker, 'keep') + await symlink( + outside, + join(pluginsDir, 'orca-samples.demo'), + process.platform === 'win32' ? 'junction' : 'dir' + ) + + await expect( + removeInstalledPlugin({ + pluginsDir, + pluginsDataDir, + pluginKey: 'orca-samples.demo' + }) + ).rejects.toThrow('outside') + await expect(readFile(marker, 'utf8')).resolves.toBe('keep') + }) + + it('removes qualified install and data directories', async () => { + const pluginsDir = await tempRoot('orca-plugin-installs-') + const pluginsDataDir = await tempRoot('orca-plugin-data-') + const key = 'orca-samples.demo' + await mkdir(join(pluginsDir, key)) + await mkdir(join(pluginsDataDir, key)) + await writeFile(join(pluginsDir, key, 'content'), 'installed') + await writeFile(join(pluginsDataDir, key, 'storage.json'), '{}') + + await removeInstalledPlugin({ pluginsDir, pluginsDataDir, pluginKey: key }) + + await expect(readFile(join(pluginsDir, key, 'content'))).rejects.toThrow() + await expect(readFile(join(pluginsDataDir, key, 'storage.json'))).rejects.toThrow() + }) +}) diff --git a/src/main/plugins/plugin-install.ts b/src/main/plugins/plugin-install.ts new file mode 100644 index 00000000000..df3b6b4ed74 --- /dev/null +++ b/src/main/plugins/plugin-install.ts @@ -0,0 +1,319 @@ +import { mkdtemp, readdir, realpath, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { isAbsolute, join, relative, resolve, sep } from 'node:path' +import { + PLUGIN_MANIFEST_FILENAME, + isQualifiedPluginKey +} from '../../shared/plugins/plugin-manifest' +import { + isAllowedPluginGitUrl, + PLUGIN_COMMIT_PATTERN, + PLUGIN_CONTENT_HASH_PATTERN, + pluginInstallSourceSchema, + removePluginLock +} from '../../shared/plugins/plugin-install-lockfile' +import { readPluginLockfile, writePluginLockfile } from './plugin-install-lockfile-store' +import { + inspectPluginInstallTree, + installStagedPluginTree, + type PluginInstallResult +} from './plugin-install-staging' +import { checkoutPluginGitSource } from './plugin-git-repository' +import { readPluginCurrentPointer } from './plugin-current-pointer' +import { readPluginInstallProvenance } from './plugin-install-provenance' +import { publishPluginInstall } from './plugin-install-publication' + +export type { PluginInstallResult } from './plugin-install-staging' + +export { + PLUGIN_LOCKFILE_MAX_BYTES, + pluginLockfilePath, + readPluginLockfile +} from './plugin-install-lockfile-store' + +/** + * Plugin installer, v0 sources: local path + git URL `#ref`. Git operations + * shell out to SYSTEM git (execFile, argv arrays — never a shell string, and + * never a vendored checkout: private repos must work with the user's + * existing credential helpers and SSH remotes). No script execution during + * install, ever — the installer copies files, nothing more. + * + * Installs land in immutable hash-addressed dirs behind an atomic pointer + * swap; the previous version dir is kept for one-step rollback. + */ + +const pluginMutationChains = new Map>() + +async function serializePluginMutation( + pluginsDir: string, + operation: () => Promise +): Promise { + const previous = pluginMutationChains.get(pluginsDir) ?? Promise.resolve() + const run = previous.catch(() => undefined).then(operation) + const settled = run.then( + () => undefined, + () => undefined + ) + pluginMutationChains.set(pluginsDir, settled) + try { + return await run + } finally { + if (pluginMutationChains.get(pluginsDir) === settled) { + pluginMutationChains.delete(pluginsDir) + } + } +} + +export async function installPluginFromLocalPath(input: { + pluginsDir: string + sourcePath: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + return serializePluginMutation(input.pluginsDir, async () => { + if (!existsSync(join(input.sourcePath, PLUGIN_MANIFEST_FILENAME))) { + return { ok: false, error: `no ${PLUGIN_MANIFEST_FILENAME} found in ${input.sourcePath}` } + } + return installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir: input.sourcePath, + hostVersion: input.hostVersion, + source: { kind: 'local-path', path: input.sourcePath }, + resolvedCommit: null, + blockedPluginReason: input.blockedPluginReason + }) + }) +} + +export async function installBundledPlugin(input: { + pluginsDir: string + sourcePath: string + hostVersion: string + expectedPluginKey: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + return serializePluginMutation(input.pluginsDir, () => + installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir: input.sourcePath, + hostVersion: input.hostVersion, + source: { kind: 'bundled', bundleId: input.expectedPluginKey }, + resolvedCommit: null, + expectedPluginKey: input.expectedPluginKey, + repairCorruptedVersion: true, + blockedPluginReason: input.blockedPluginReason + }) + ) +} + +export async function installPluginFromGit(input: { + pluginsDir: string + url: string + /** `#ref` suffix: branch, tag, or full commit SHA. Empty = default branch. */ + ref: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + if (!isAllowedPluginGitUrl(input.url)) { + return { ok: false, error: 'plugin Git URL must use HTTPS or SSH' } + } + return serializePluginMutation(input.pluginsDir, async () => { + const stagingDir = await mkdtemp(join(tmpdir(), 'orca-plugin-install-')) + try { + const ref = input.ref.trim() + const resolvedCommit = await checkoutPluginGitSource({ + url: input.url, + ref, + destination: stagingDir, + workingDirectory: tmpdir() + }) + return await installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir, + hostVersion: input.hostVersion, + source: { kind: 'git', url: input.url, ref }, + resolvedCommit, + blockedPluginReason: input.blockedPluginReason + }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } + }) +} + +export async function installPluginFromMarketplace(input: { + pluginsDir: string + hostVersion: string + expectedPluginKey: string + expectedResolvedCommit: string + marketplace: { url: string; ref: string; resolvedCommit: string } + plugin: { url: string; ref: string } + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + const source = pluginInstallSourceSchema.parse({ + kind: 'marketplace', + marketplace: input.marketplace, + plugin: input.plugin + }) + if (!isQualifiedPluginKey(input.expectedPluginKey)) { + return { ok: false, error: 'invalid marketplace plugin identity' } + } + if (!PLUGIN_COMMIT_PATTERN.test(input.expectedResolvedCommit)) { + return { ok: false, error: 'invalid previewed plugin commit' } + } + return serializePluginMutation(input.pluginsDir, async () => { + const stagingDir = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-install-')) + try { + const resolvedCommit = await checkoutPluginGitSource({ + url: input.plugin.url, + ref: input.plugin.ref, + destination: stagingDir, + workingDirectory: tmpdir() + }) + if (resolvedCommit !== input.expectedResolvedCommit) { + return { ok: false, error: 'plugin source changed after preview; review the update again' } + } + return await installStagedPluginTree({ + pluginsDir: input.pluginsDir, + stagingDir, + hostVersion: input.hostVersion, + source, + resolvedCommit, + expectedPluginKey: input.expectedPluginKey, + blockedPluginReason: input.blockedPluginReason + }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } finally { + await rm(stagingDir, { recursive: true, force: true }) + } + }) +} + +/** Restores the single retained immutable predecessor. The old consent + * fingerprint becomes current again, so enablement still fails closed until + * the user has approved those exact bytes. */ +export async function rollbackInstalledPlugin(input: { + pluginsDir: string + pluginKey: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null +}): Promise { + if (!isQualifiedPluginKey(input.pluginKey)) { + return { ok: false, error: 'invalid qualified plugin key' } + } + const blockedReason = input.blockedPluginReason?.(input.pluginKey) + if (blockedReason) { + return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } + } + return serializePluginMutation(input.pluginsDir, async () => { + const pluginDir = join(input.pluginsDir, input.pluginKey) + const currentContentHash = await readPluginCurrentPointer(pluginDir).catch(() => null) + if (!currentContentHash) { + return { ok: false, error: 'installed plugin has no current version' } + } + const candidates = (await readdir(pluginDir, { withFileTypes: true }).catch(() => [])) + .filter( + (entry) => + entry.isDirectory() && + PLUGIN_CONTENT_HASH_PATTERN.test(entry.name) && + entry.name !== currentContentHash + ) + .map((entry) => entry.name) + if (candidates.length !== 1) { + return { + ok: false, + error: + candidates.length === 0 + ? 'no rollback version is available' + : 'rollback state is ambiguous' + } + } + const contentHash = candidates[0]! + const provenance = await readPluginInstallProvenance(pluginDir, contentHash) + if ( + !provenance || + provenance.pluginKey !== input.pluginKey || + provenance.contentHash !== contentHash + ) { + return { ok: false, error: 'rollback version has no valid install provenance' } + } + const inspection = await inspectPluginInstallTree({ + rootDir: join(pluginDir, contentHash), + hostVersion: input.hostVersion, + expectedPluginKey: input.pluginKey + }) + if (!inspection.ok || inspection.contentHash !== contentHash) { + return { + ok: false, + error: inspection.ok ? 'rollback version failed integrity verification' : inspection.error + } + } + try { + await publishPluginInstall({ pluginsDir: input.pluginsDir, pluginDir, entry: provenance }) + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } + return { + ok: true, + pluginKey: input.pluginKey, + version: inspection.manifest.version, + contentHash, + consentFingerprint: provenance.consentFingerprint, + resolvedCommit: provenance.resolvedCommit + } + }) +} + +/** Removes the install dir, the plugin's data dir, and the lock entry. */ +export async function removeInstalledPlugin(input: { + pluginsDir: string + pluginsDataDir: string + pluginKey: string +}): Promise { + await serializePluginMutation(input.pluginsDir, async () => { + if (!isQualifiedPluginKey(input.pluginKey)) { + throw new Error(`invalid qualified plugin key: ${input.pluginKey}`) + } + await removeResolvedPluginDirectory(input.pluginsDir, input.pluginKey) + await removeResolvedPluginDirectory(input.pluginsDataDir, input.pluginKey) + await writePluginLockfile( + input.pluginsDir, + removePluginLock(await readPluginLockfile(input.pluginsDir), input.pluginKey) + ) + }) +} + +async function removeResolvedPluginDirectory(rootDir: string, pluginKey: string): Promise { + let rootReal: string + let targetReal: string + try { + rootReal = await realpath(resolve(rootDir)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + try { + targetReal = await realpath(resolve(rootDir, pluginKey)) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return + } + throw error + } + const fromRoot = relative(rootReal, targetReal) + if ( + fromRoot.length === 0 || + isAbsolute(fromRoot) || + fromRoot === '..' || + fromRoot.startsWith(`..${sep}`) + ) { + throw new Error(`refusing to remove plugin path outside ${rootReal}`) + } + await rm(resolve(rootDir, pluginKey), { recursive: true, force: true }) +} diff --git a/src/main/plugins/plugin-instructional-content-integrity.ts b/src/main/plugins/plugin-instructional-content-integrity.ts new file mode 100644 index 00000000000..fe2c9a3dc01 --- /dev/null +++ b/src/main/plugins/plugin-instructional-content-integrity.ts @@ -0,0 +1,30 @@ +import { hasInstructionalPluginContributions } from '../../shared/plugins/plugin-consent-fingerprint' +import { hashPluginTree } from './plugin-content-hash' +import type { ValidDiscoveredPlugin } from './plugin-discovery' + +/** Instructional bytes execute later, so every read must still match the tree + * identity the user reviewed rather than a cached discovery-time snapshot. */ +export async function verifyInstructionalPluginContent( + plugin: ValidDiscoveredPlugin +): Promise { + if (!hasInstructionalPluginContributions(plugin.manifest)) { + return + } + if (!plugin.consentContentHash) { + throw new Error(`plugin ${plugin.pluginKey} has no instructional consent content identity`) + } + const actual = await hashPluginTree(plugin.rootDir) + if (!actual.ok) { + throw new Error( + `plugin ${plugin.pluginKey} instructional content is unreadable: ${actual.error}` + ) + } + const matches = + actual.hash === plugin.consentContentHash || + (plugin.consentContentHash.length === 32 && actual.hash.startsWith(plugin.consentContentHash)) + if (!matches) { + throw new Error( + `plugin ${plugin.pluginKey} instructional content changed since it was reviewed` + ) + } +} diff --git a/src/main/plugins/plugin-kill-list-service.test.ts b/src/main/plugins/plugin-kill-list-service.test.ts new file mode 100644 index 00000000000..3da97e08576 --- /dev/null +++ b/src/main/plugins/plugin-kill-list-service.test.ts @@ -0,0 +1,111 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginKillList } from '../../shared/plugins/plugin-kill-list' +import { fetchPluginKillList, PluginKillListService } from './plugin-kill-list-service' +import type { PluginKillListStore } from './plugin-kill-list-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-kill-list-')) + roots.push(root) + return root +} + +function killList(date = '2026-07-12T20:00:00Z'): PluginKillList { + return { + version: 1, + generatedAt: date, + plugins: [{ pluginKey: 'community.unsafe', reason: 'Malware advisory' }] + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginKillListService', () => { + it('loads cached revocations before any network refresh', async () => { + const root = await tempRoot() + const first = new PluginKillListService({ + pluginsDataDir: root, + fetcher: async () => killList() + }) + await first.refresh() + const fetcher = vi.fn(async () => killList()) + const restarted = new PluginKillListService({ pluginsDataDir: root, fetcher }) + + await restarted.initialize() + + expect(restarted.reason('community.unsafe')).toBe('Malware advisory') + expect(fetcher).not.toHaveBeenCalled() + }) + + it('publishes valid refreshes and notifies runtime reconciliation', async () => { + const service = new PluginKillListService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => killList() + }) + const changed = vi.fn() + service.onChanged(changed) + + await service.refresh() + + expect(service.find('community.unsafe')).toMatchObject({ reason: 'Malware advisory' }) + expect(changed).toHaveBeenCalledTimes(1) + }) + + it('starts with no revocations after a corrupt cache and accepts a valid refresh', async () => { + const store = { + read: vi.fn().mockRejectedValue(new Error('invalid JSON')), + write: vi.fn().mockResolvedValue(undefined) + } as unknown as PluginKillListStore + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const service = new PluginKillListService({ + pluginsDataDir: await tempRoot(), + store, + fetcher: async () => killList() + }) + + await expect(service.initialize()).resolves.toBeUndefined() + expect(service.snapshot()).toBeNull() + await expect(service.refresh()).resolves.toEqual(killList()) + expect(service.reason('community.unsafe')).toBe('Malware advisory') + expect(warning).toHaveBeenCalledWith( + '[plugins] ignoring invalid cached plugin safety list:', + expect.any(Error) + ) + }) + + it('rejects a replayed older snapshot without replacing cached revocations', async () => { + const fetcher = vi + .fn<() => Promise>() + .mockResolvedValueOnce(killList('2026-07-12T20:00:00Z')) + .mockResolvedValueOnce(killList('2026-07-11T20:00:00Z')) + const service = new PluginKillListService({ pluginsDataDir: await tempRoot(), fetcher }) + await service.refresh() + + await expect(service.refresh()).rejects.toThrow('older snapshot') + expect(service.snapshot()?.generatedAt).toBe('2026-07-12T20:00:00Z') + }) +}) + +describe('fetchPluginKillList', () => { + it('validates a bounded HTTPS response body', async () => { + const fetcher = vi.fn().mockResolvedValue( + new Response(JSON.stringify(killList()), { + status: 200, + headers: { 'content-type': 'application/json' } + }) + ) + + await expect(fetchPluginKillList(fetcher)).resolves.toEqual(killList()) + }) + + it('rejects non-success responses', async () => { + const fetcher = vi.fn().mockResolvedValue(new Response('no', { status: 503 })) + await expect(fetchPluginKillList(fetcher)).rejects.toThrow('HTTP 503') + }) +}) diff --git a/src/main/plugins/plugin-kill-list-service.ts b/src/main/plugins/plugin-kill-list-service.ts new file mode 100644 index 00000000000..572e7074b66 --- /dev/null +++ b/src/main/plugins/plugin-kill-list-service.ts @@ -0,0 +1,140 @@ +import { + findKilledPlugin, + pluginKillListSchema, + type PluginKillList, + type PluginKillListEntry +} from '../../shared/plugins/plugin-kill-list' +import { PluginKillListStore } from './plugin-kill-list-store' + +export const PLUGIN_KILL_LIST_URL = 'https://onorca.dev/plugins/kill-list.json' +const PLUGIN_KILL_LIST_DOWNLOAD_LIMIT = 4 * 1024 * 1024 + +type PluginKillListFetcher = () => Promise + +export class PluginKillListService { + private readonly store: PluginKillListStore + private readonly fetcher: PluginKillListFetcher + private readonly listeners = new Set<() => void>() + private currentList: PluginKillList | null = null + private loadPromise: Promise | null = null + private refreshChain: Promise = Promise.resolve({ + version: 1, + generatedAt: '1970-01-01T00:00:00Z', + plugins: [] + }) + + constructor(options: { + pluginsDataDir: string + store?: PluginKillListStore + fetcher?: PluginKillListFetcher + }) { + this.store = options.store ?? new PluginKillListStore(options.pluginsDataDir) + this.fetcher = options.fetcher ?? (() => fetchPluginKillList()) + } + + async initialize(): Promise { + this.loadPromise ??= this.store + .read() + .then((killList) => { + this.currentList = killList + }) + .catch((error) => { + // Why: an unusable cache must not prevent Orca from starting; a valid + // network refresh can still restore runtime revocations this session. + console.warn('[plugins] ignoring invalid cached plugin safety list:', error) + this.currentList = null + }) + await this.loadPromise + } + + onChanged(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + find(pluginKey: string): PluginKillListEntry | null { + return this.currentList ? findKilledPlugin(this.currentList, pluginKey) : null + } + + reason(pluginKey: string): string | null { + return this.find(pluginKey)?.reason ?? null + } + + snapshot(): PluginKillList | null { + return this.currentList + } + + refresh(): Promise { + const refresh = this.refreshChain + .catch(() => this.currentList ?? emptyKillList()) + .then(() => this.performRefresh()) + this.refreshChain = refresh + return refresh + } + + private async performRefresh(): Promise { + await this.initialize() + const fetched = pluginKillListSchema.parse(await this.fetcher()) + if ( + this.currentList && + Date.parse(fetched.generatedAt) < Date.parse(this.currentList.generatedAt) + ) { + throw new Error('refusing to replace the plugin kill list with an older snapshot') + } + await this.store.write(fetched) + this.currentList = fetched + for (const listener of this.listeners) { + listener() + } + return fetched + } +} + +export async function fetchPluginKillList( + fetcher: typeof fetch = fetch, + url = PLUGIN_KILL_LIST_URL +): Promise { + const response = await fetcher(url, { cache: 'no-store' }) + if (!response.ok) { + throw new Error(`plugin kill-list request failed with HTTP ${response.status}`) + } + const declaredBytes = Number(response.headers.get('content-length') ?? '0') + if (Number.isFinite(declaredBytes) && declaredBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) { + throw new Error('plugin kill-list response exceeds its size limit') + } + if (!response.body) { + throw new Error('plugin kill-list response has no body') + } + const reader = response.body.getReader() + const chunks: Uint8Array[] = [] + let totalBytes = 0 + while (true) { + const chunk = await reader.read() + if (chunk.done) { + break + } + totalBytes += chunk.value.byteLength + if (totalBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) { + await reader.cancel() + throw new Error('plugin kill-list response exceeds its size limit') + } + chunks.push(chunk.value) + } + const bytes = new Uint8Array(totalBytes) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.byteLength + } + try { + return pluginKillListSchema.parse(JSON.parse(new TextDecoder().decode(bytes))) + } catch (error) { + throw new Error( + `invalid plugin kill-list response: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function emptyKillList(): PluginKillList { + return { version: 1, generatedAt: '1970-01-01T00:00:00Z', plugins: [] } +} diff --git a/src/main/plugins/plugin-kill-list-store.ts b/src/main/plugins/plugin-kill-list-store.ts new file mode 100644 index 00000000000..5dca14190fd --- /dev/null +++ b/src/main/plugins/plugin-kill-list-store.ts @@ -0,0 +1,46 @@ +import { createReadStream } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { pluginKillListSchema, type PluginKillList } from '../../shared/plugins/plugin-kill-list' +import { writePluginFileAtomically } from './plugin-atomic-file-write' + +const PLUGIN_KILL_LIST_MAX_BYTES = 4 * 1024 * 1024 + +export class PluginKillListStore { + private readonly filePath: string + + constructor(pluginsDataDir: string) { + this.filePath = join(pluginsDataDir, 'plugin-kill-list.json') + } + + async read(): Promise { + try { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(this.filePath)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_KILL_LIST_MAX_BYTES) { + throw new Error('plugin kill list exceeds its size limit') + } + chunks.push(bytes) + } + return pluginKillListSchema.parse( + JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw new Error( + `cached plugin kill list is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + async write(killList: PluginKillList): Promise { + const parsed = pluginKillListSchema.parse(killList) + await mkdir(dirname(this.filePath), { recursive: true }) + await writePluginFileAtomically(this.filePath, `${JSON.stringify(parsed, null, 2)}\n`) + } +} diff --git a/src/main/plugins/plugin-language-pack-registry.test.ts b/src/main/plugins/plugin-language-pack-registry.test.ts new file mode 100644 index 00000000000..94fd93e45ff --- /dev/null +++ b/src/main/plugins/plugin-language-pack-registry.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginLanguagePackRegistry } from './plugin-language-pack-registry' + +const roots: string[] = [] + +async function pluginWithCatalog(catalog: unknown): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-language-registry-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'locales')) + await writeFile(join(rootDir, 'locales', 'pt-BR.json'), JSON.stringify(catalog)) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'portuguese', + publisher: 'orca-samples', + name: 'Portuguese', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + languagePacks: [{ locale: 'pt-BR', path: 'locales/pt-BR.json' }] + }, + capabilities: [] + }) + return { + pluginKey: 'orca-samples.portuguese', + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest), + contentHash: null, + isDev: true + } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginLanguagePackRegistry', () => { + it('loads approved catalogs under an isolated plugin language id', async () => { + const plugin = await pluginWithCatalog({ common: { save: 'Salvar' } }) + const registry = new PluginLanguagePackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([ + { + id: 'plugin:orca-samples.portuguese/pt-BR', + resourceLanguage: + 'plugin0070006c007500670069006e003a006f007200630061002d00730061006d0070006c00650073002e0070006f00720074007500670075006500730065002f00700074002d00420052', + pluginKey: 'orca-samples.portuguese', + locale: 'pt-BR', + catalog: { common: { save: 'Salvar' } } + } + ]) + expect(registry.error(plugin.pluginKey)).toBeNull() + }) + + it('fails closed for protected security copy and clears state when disabled', async () => { + const plugin = await pluginWithCatalog({ + auto: { components: { settings: { PluginConsentDialog: { disclaimer: 'Safe' } } } } + }) + const registry = new PluginLanguagePackRegistry(new PluginContentVerifier()) + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toEqual([]) + expect(registry.error(plugin.pluginKey)).toContain('protected security copy') + + await registry.reconcile([plugin], () => false) + expect(registry.error(plugin.pluginKey)).toBeNull() + }) +}) diff --git a/src/main/plugins/plugin-language-pack-registry.ts b/src/main/plugins/plugin-language-pack-registry.ts new file mode 100644 index 00000000000..df409264772 --- /dev/null +++ b/src/main/plugins/plugin-language-pack-registry.ts @@ -0,0 +1,96 @@ +import { + parsePluginLanguagePackArtifact, + pluginLanguageResourceId, + type PluginLanguagePackRegistration +} from '../../shared/plugins/plugin-language-pack-artifact' +import { + PLUGIN_LANGUAGE_PACK_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' + +const LANGUAGE_PACK_LOAD_CONCURRENCY = 4 + +type LanguageLoadResult = + | { pluginKey: string; packs: PluginLanguagePackRegistration[] } + | { pluginKey: string; error: string } + +export class PluginLanguagePackRegistry { + private packs: PluginLanguagePackRegistration[] = [] + private readonly errors = new Map() + + constructor(private readonly contentVerifier: PluginContentVerifier) {} + + list(): readonly PluginLanguagePackRegistration[] { + return this.packs + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates: ValidDiscoveredPlugin[] = [] + for (const plugin of discovered) { + if ( + !isInvalidDiscoveredPlugin(plugin) && + isApproved(plugin) && + plugin.manifest.contributes.languagePacks.length > 0 + ) { + candidates.push(plugin) + } + } + const results = await mapWithConcurrency( + candidates, + LANGUAGE_PACK_LOAD_CONCURRENCY, + async (plugin): Promise => { + try { + await this.contentVerifier.verify(plugin) + const packs = await Promise.all( + plugin.manifest.contributes.languagePacks.map(async (contribution) => { + const text = await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_LANGUAGE_PACK_MAX_BYTES + ) + const parsed = parsePluginLanguagePackArtifact(text) + if (!parsed.ok) { + throw new Error(`language pack "${contribution.locale}" ${parsed.error}`) + } + const id = `plugin:${plugin.pluginKey}/${contribution.locale}` as const + return { + id, + resourceLanguage: pluginLanguageResourceId(id), + pluginKey: plugin.pluginKey, + locale: contribution.locale, + catalog: parsed.catalog + } + }) + ) + return { pluginKey: plugin.pluginKey, packs } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + this.packs = results.flatMap((result) => ('packs' in result ? result.packs : [])) + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } + } + } +} diff --git a/src/main/plugins/plugin-launch-content.test.ts b/src/main/plugins/plugin-launch-content.test.ts new file mode 100644 index 00000000000..692a987555c --- /dev/null +++ b/src/main/plugins/plugin-launch-content.test.ts @@ -0,0 +1,113 @@ +import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + isOfficialOrganizationGitSource, + isOfficialPluginIdentity, + pluginMarketplaceSchema +} from '../../shared/plugins/plugin-marketplace' +import { bootstrapBundledPlugins, resolveBundledPluginRoot } from './plugin-bundled-bootstrap' +import { inspectPluginInstallTree } from './plugin-install-staging' + +const launchRoot = join(process.cwd(), 'resources', 'plugins', 'launch') +const temporaryRoots: string[] = [] + +async function readJson(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) +} + +afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('Phase 1 launch plugin content', () => { + it('lists and validates the launch plugin packs', async () => { + const marketplace = pluginMarketplaceSchema.parse( + await readJson(join(launchRoot, 'orca-marketplace.json')) + ) + expect(marketplace.plugins.map((plugin) => plugin.id).sort()).toEqual([ + 'stablyai.orca-multipass-recipes', + 'stablyai.orca-navigation-shortcuts', + 'stablyai.orca-portuguese' + ]) + expect( + marketplace.plugins.filter( + (plugin) => + isOfficialPluginIdentity(plugin.id) && isOfficialOrganizationGitSource(plugin.source.url) + ).length + ).toBeGreaterThanOrEqual(2) + + const localPluginDirectories = (await readdir(launchRoot, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + expect(marketplace.plugins.map((plugin) => plugin.id).sort()).toEqual(localPluginDirectories) + + const contributionKinds = new Set() + for (const listing of marketplace.plugins) { + const inspection = await inspectPluginInstallTree({ + rootDir: join(launchRoot, listing.id), + hostVersion: '1.4.0', + expectedPluginKey: listing.id + }) + expect(inspection, `${listing.id} must pass the production install inspection`).toMatchObject( + { + ok: true + } + ) + if (!inspection.ok) { + continue + } + const contributes = inspection.manifest.contributes + if (contributes.languagePacks.length > 0) { + contributionKinds.add('language') + } + if (contributes.vmRecipes.length > 0) { + contributionKinds.add('vm-recipe') + } + if (contributes.commands.length > 0 && contributes.keybindings.length > 0) { + contributionKinds.add('command-keybinding') + } + } + expect(contributionKinds).toEqual(new Set(['language', 'vm-recipe', 'command-keybinding'])) + }) + + it('publishes every bundled pack only when its release hash matches exact bytes', async () => { + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-launch-content-')) + temporaryRoots.push(userDataPath) + + const result = await bootstrapBundledPlugins({ + root: launchRoot, + userDataPath, + hostVersion: '1.4.0' + }) + + expect(result.errors).toEqual([]) + expect(result.installed.length).toBeGreaterThanOrEqual(1) + expect(result.installed.every(isOfficialPluginIdentity)).toBe(true) + }) + + it('boots release-indexed content from the packaged resources layout', async () => { + const resourcesPath = await mkdtemp(join(tmpdir(), 'orca-packaged-resources-')) + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-packaged-user-data-')) + temporaryRoots.push(resourcesPath, userDataPath) + const packagedRoot = join(resourcesPath, 'plugins', 'launch') + await cp(launchRoot, packagedRoot, { recursive: true }) + + const result = await bootstrapBundledPlugins({ + root: resolveBundledPluginRoot({ + isPackaged: true, + resourcesPath, + appPath: join(resourcesPath, 'app.asar') + }), + userDataPath, + hostVersion: '1.4.0' + }) + + expect(result.errors).toEqual([]) + expect(result.installed).toEqual(['stablyai.orca-navigation-shortcuts']) + }) +}) diff --git a/src/main/plugins/plugin-list-projection.test.ts b/src/main/plugins/plugin-list-projection.test.ts new file mode 100644 index 00000000000..36904cfa262 --- /dev/null +++ b/src/main/plugins/plugin-list-projection.test.ts @@ -0,0 +1,224 @@ +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { emptyPluginLockfile } from '../../shared/plugins/plugin-install-lockfile' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { InvalidDiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery' +import { buildPluginList } from './plugin-list-projection' +import type { PluginService } from './plugin-service' + +const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { panels: [], commands: [], events: [] }, + capabilities: [{ kind: 'workspace:read' }] +}) + +function serviceWith( + discovered: ValidDiscoveredPlugin, + options: { + activation?: ReturnType + worker?: ReturnType + vmRecipes?: ReturnType + commands?: ReturnType + } = {} +): PluginService { + return { + options: { + getPluginConsents: () => ({}), + getDisabledPlugins: () => [] + }, + getDiscovered: () => [discovered], + activationState: () => options.activation ?? 'pending', + workerState: () => options.worker ?? { state: 'inactive', restarts: 0 }, + activationError: () => null, + contentPacks: { + vmRecipes: { preview: () => options.vmRecipes ?? [] }, + commands: { preview: () => options.commands ?? [] } + } + } as unknown as PluginService +} + +describe('buildPluginList consent identity', () => { + it('projects the exact current fingerprint for an optimistic consent write', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest, + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } + + expect((await buildPluginList(serviceWith(plugin), emptyPluginLockfile()))[0]).toMatchObject({ + pluginKey: plugin.pluginKey, + consentFingerprint: 'sha256-current', + status: 'pending' + }) + }) + + it('projects supervised backoff as restarting instead of running', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest, + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } + + expect( + ( + await buildPluginList( + serviceWith(plugin, { + activation: 'approved', + worker: { state: 'restarting', restarts: 2 } + }), + emptyPluginLockfile() + ) + )[0] + ).toMatchObject({ status: 'restarting', restarts: 2 }) + }) + + it('does not attribute a shadowing dev plugin to the installed source', async () => { + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'development', 'demo'), + manifest, + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } + const lock = { + version: 1 as const, + plugins: { + [plugin.pluginKey]: { + pluginKey: plugin.pluginKey, + version: '1.0.0', + source: { kind: 'git' as const, url: 'https://example.com/demo.git', ref: 'v1' }, + resolvedCommit: 'a'.repeat(40), + contentHash: 'b'.repeat(64), + consentFingerprint: 'sha256-installed', + installedAt: 1 + } + } + } + + expect((await buildPluginList(serviceWith(plugin), lock))[0]).not.toHaveProperty('source') + }) + + it('does not expose an invalid development plugin absolute path as identity', async () => { + const invalid: InvalidDiscoveredPlugin = { + rootDir: join(tmpdir(), 'private', 'secret-plugin-path'), + error: 'missing orca-plugin.json', + isDev: true + } + const service = { + options: { getPluginConsents: () => ({}), getDisabledPlugins: () => [] }, + getDiscovered: () => [invalid] + } as unknown as PluginService + + const projected = (await buildPluginList(service, emptyPluginLockfile()))[0]! + expect(projected.pluginKey).toBe('invalid-development-plugin-1') + expect(projected.name).toBe('invalid-development-plugin-1') + expect(JSON.stringify(projected)).not.toContain(invalid.rootDir) + }) + + it('projects exact VM lifecycle commands for instructional consent', async () => { + const recipeManifest = pluginManifestSchema.parse({ + ...manifest, + contributes: { vmRecipes: [{ path: 'recipes/cloud.json' }] } + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest: recipeManifest, + consentFingerprint: 'sha256-current', + consentContentHash: 'a'.repeat(64), + contentHash: null, + isDev: true + } + + expect( + ( + await buildPluginList( + serviceWith(plugin, { + vmRecipes: [ + { + pluginKey: plugin.pluginKey, + recipe: { + id: 'cloud', + name: 'Cloud', + create: './create.sh', + destroyDisabled: true + } + } + ] + }), + emptyPluginLockfile() + ) + )[0]?.vmRecipes + ).toEqual([ + { + id: 'cloud', + name: 'Cloud', + commands: [ + { phase: 'create', command: './create.sh' }, + { phase: 'destroy', command: 'none' } + ] + } + ]) + }) + + it('projects command handlers and normalized keybindings for consent and dispatch', async () => { + const commandManifest = pluginManifestSchema.parse({ + ...manifest, + contributes: { + commands: [{ id: 'tasks', title: 'Open Tasks', context: 'worktree', action: 'view.tasks' }], + keybindings: [{ command: 'tasks', key: 'mod+alt+t' }] + } + }) + const plugin: ValidDiscoveredPlugin = { + pluginKey: 'orca-samples.demo', + rootDir: join(tmpdir(), 'plugins', 'demo'), + manifest: commandManifest, + consentFingerprint: 'sha256-current', + consentContentHash: 'a'.repeat(64), + contentHash: null, + isDev: true + } + + expect( + ( + await buildPluginList( + serviceWith(plugin, { + commands: [ + { + pluginKey: plugin.pluginKey, + id: 'tasks', + title: 'Open Tasks', + context: 'worktree', + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }] + } + ] + }), + emptyPluginLockfile() + ) + )[0]?.commands + ).toEqual([ + { + id: 'tasks', + title: 'Open Tasks', + context: 'worktree', + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [{ key: 'Mod+Alt+T', when: 'worktree' }] + } + ]) + }) +}) diff --git a/src/main/plugins/plugin-list-projection.ts b/src/main/plugins/plugin-list-projection.ts new file mode 100644 index 00000000000..16d44935b18 --- /dev/null +++ b/src/main/plugins/plugin-list-projection.ts @@ -0,0 +1,231 @@ +import { + PLUGIN_CAPABILITY_DESCRIPTIONS, + type PluginCapabilityKind +} from '../../shared/plugins/plugin-capabilities' +import { needsReconsent } from '../../shared/plugins/plugin-consent-state' +import { pluginPanelTabKey } from '../../shared/plugins/plugin-manifest' +import type { PluginLockfile } from '../../shared/plugins/plugin-install-lockfile' +import { isInvalidDiscoveredPlugin } from './plugin-discovery' +import type { PluginService } from './plugin-service' +import { listPluginVmRecipeCommands } from '../../shared/plugins/plugin-vm-recipe-artifact' +import type { PluginCommandAliasActionId } from '../../shared/plugins/plugin-command-actions' +import { + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + isOfficialPluginIdentity +} from '../../shared/plugins/plugin-marketplace' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' + +const PLUGIN_LIST_PROJECTION_CONCURRENCY = 4 + +/** + * Wire projection of installed plugins for the renderer and serve RPC. + * `invalid` = unreadable/failed manifest; `pending` = awaiting (re-)consent; + * `idle` = enabled with no worker running (lazy); `restarting` = waiting for + * supervised backoff; `errored` = crashed past the budget or failed to activate. + */ + +export type PluginListPanelEntry = { + id: string + title: string + icon?: string + tabKey: `plugin:${string}` +} + +export type PluginListStatus = + | 'running' + | 'restarting' + | 'idle' + | 'pending' + | 'disabled' + | 'errored' + | 'invalid' + +export type PluginListEntry = { + pluginKey: string + /** Opaque identity of the exact capabilities and worker tier shown for review. */ + consentFingerprint: string | null + name: string + version: string + publisher: string + description?: string + status: PluginListStatus + needsReconsent: boolean + error?: string + isDev: boolean + official: boolean + bundled: boolean + capabilities: { kind: PluginCapabilityKind; description: string }[] + panels: PluginListPanelEntry[] + commands: { + id: string + title: string + context: 'global' | 'worktree' + handler: { type: 'built-in'; action: PluginCommandAliasActionId } | { type: 'worker' } + keybindings: { key: string; when: 'global' | 'worktree' }[] + }[] + hasWorker: boolean + vmRecipes: { + id: string + name: string + description?: string + commands: { phase: 'create' | 'suspend' | 'resume' | 'destroy'; command: string }[] + }[] + restarts: number + blockedByKillList?: { reason: string; advisoryUrl?: string } + source?: { + kind: 'local-path' | 'git' | 'marketplace' | 'bundled' + reference: string + resolvedCommit: string | null + contentHash: string + marketplace?: { reference: string; resolvedCommit: string } + } +} + +export async function buildPluginList( + service: PluginService, + lock: PluginLockfile +): Promise { + const consents = { + pluginConsents: service.options.getPluginConsents(), + disabledPlugins: service.options.getDisabledPlugins() + } + return mapWithConcurrency( + service.getDiscovered(), + PLUGIN_LIST_PROJECTION_CONCURRENCY, + async (plugin, index): Promise => { + if (isInvalidDiscoveredPlugin(plugin)) { + // Why: invalid dev paths can contain private absolute desktop paths; + // never project those as identity over desktop/serve transports. + const fallbackKey = plugin.pluginKey ?? `invalid-development-plugin-${index + 1}` + return { + pluginKey: fallbackKey, + consentFingerprint: null, + name: fallbackKey, + version: '0.0.0', + publisher: '', + status: 'invalid' as const, + needsReconsent: false, + error: plugin.error, + isDev: plugin.isDev, + official: false, + bundled: false, + capabilities: [], + panels: [], + commands: [], + hasWorker: false, + vmRecipes: [], + restarts: 0 + } + } + const activation = service.activationState(plugin) + const worker = service.workerState(plugin.pluginKey) + const activationError = service.activationError(plugin.pluginKey) + const killListEntry = service.options.getPluginKillListEntry?.(plugin.pluginKey) ?? null + let status: PluginListStatus + if (activation === 'disabled') { + status = 'disabled' + } else if (activation === 'pending') { + status = 'pending' + } else if (worker.state === 'errored' || activationError) { + status = 'errored' + } else if (worker.state === 'restarting') { + status = 'restarting' + } else { + status = worker.state === 'running' ? 'running' : 'idle' + } + const candidateLockEntry = lock.plugins[plugin.pluginKey] + // Why: never show provenance for bytes other than the current executable + // identity. Dev overrides execute outside the immutable installed tree and + // must never inherit the shadowed install's pinned-source attribution. + const lockEntry = + candidateLockEntry && + !plugin.isDev && + plugin.contentHash !== null && + candidateLockEntry.contentHash === plugin.contentHash + ? candidateLockEntry + : undefined + const bundled = lockEntry?.source.kind === 'bundled' + const official = + bundled || + (lockEntry?.source.kind === 'marketplace' && + isOfficialPluginIdentity(plugin.pluginKey) && + isOfficialMarketplaceGitSource(lockEntry.source.marketplace.url) && + isOfficialOrganizationGitSource(lockEntry.source.plugin.url)) + return { + pluginKey: plugin.pluginKey, + consentFingerprint: plugin.consentFingerprint, + name: plugin.manifest.name, + version: plugin.manifest.version, + publisher: plugin.manifest.publisher, + ...(plugin.manifest.description ? { description: plugin.manifest.description } : {}), + status, + needsReconsent: needsReconsent(plugin.pluginKey, plugin.consentFingerprint, consents), + ...(status === 'errored' + ? { error: activationError ?? 'plugin worker crashed repeatedly' } + : {}), + isDev: plugin.isDev, + official, + bundled, + capabilities: plugin.manifest.capabilities.map((capability) => ({ + kind: capability.kind, + description: PLUGIN_CAPABILITY_DESCRIPTIONS[capability.kind] + })), + panels: plugin.manifest.contributes.panels.map((panel) => ({ + id: panel.id, + title: panel.title, + ...(panel.icon ? { icon: panel.icon } : {}), + tabKey: pluginPanelTabKey(plugin.pluginKey, panel.id) + })), + commands: service.contentPacks.commands.preview(plugin.pluginKey).map((command) => ({ + id: command.id, + title: command.title, + context: command.context, + handler: command.handler, + keybindings: command.keybindings + })), + hasWorker: Boolean(plugin.manifest.main), + vmRecipes: service.contentPacks.vmRecipes.preview(plugin.pluginKey).map(({ recipe }) => ({ + id: recipe.id, + name: recipe.name, + ...(recipe.description ? { description: recipe.description } : {}), + commands: listPluginVmRecipeCommands(recipe) + })), + restarts: worker.restarts, + ...(killListEntry + ? { + blockedByKillList: { + reason: killListEntry.reason, + ...(killListEntry.advisoryUrl ? { advisoryUrl: killListEntry.advisoryUrl } : {}) + } + } + : {}), + ...(lockEntry + ? { + source: { + kind: lockEntry.source.kind, + reference: + lockEntry.source.kind === 'local-path' + ? lockEntry.source.path + : lockEntry.source.kind === 'git' + ? lockEntry.source.url + : lockEntry.source.kind === 'marketplace' + ? lockEntry.source.plugin.url + : `bundled:${lockEntry.source.bundleId}`, + resolvedCommit: lockEntry.resolvedCommit, + contentHash: lockEntry.contentHash, + ...(lockEntry.source.kind === 'marketplace' + ? { + marketplace: { + reference: lockEntry.source.marketplace.url, + resolvedCommit: lockEntry.source.marketplace.resolvedCommit + } + } + : {}) + } + } + : {}) + } + } + ) +} diff --git a/src/main/plugins/plugin-log-buffer.ts b/src/main/plugins/plugin-log-buffer.ts new file mode 100644 index 00000000000..54fbdcd83b8 --- /dev/null +++ b/src/main/plugins/plugin-log-buffer.ts @@ -0,0 +1,20 @@ +export type PluginLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string } + +const LOG_RING_LIMIT = 200 + +export class PluginLogBuffer { + private readonly logs = new Map() + + get(pluginKey: string): PluginLogLine[] { + return this.logs.get(pluginKey) ?? [] + } + + append(pluginKey: string, level: PluginLogLine['level'], line: string): void { + const ring = this.logs.get(pluginKey) ?? [] + ring.push({ ts: Date.now(), level, line }) + if (ring.length > LOG_RING_LIMIT) { + ring.splice(0, ring.length - LOG_RING_LIMIT) + } + this.logs.set(pluginKey, ring) + } +} diff --git a/src/main/plugins/plugin-manifest-file.ts b/src/main/plugins/plugin-manifest-file.ts new file mode 100644 index 00000000000..68b01dfe1da --- /dev/null +++ b/src/main/plugins/plugin-manifest-file.ts @@ -0,0 +1,22 @@ +import { createReadStream } from 'node:fs' +import { join } from 'node:path' +import { PLUGIN_MANIFEST_FILENAME } from '../../shared/plugins/plugin-manifest' + +/** A manifest is startup metadata, not an artifact payload. Bounding it keeps + * discovery and install preview from allocating an attacker-sized JSON file. */ +export const PLUGIN_MANIFEST_MAX_BYTES = 1024 * 1024 + +export async function readPluginManifestText(rootDir: string): Promise { + const chunks: Buffer[] = [] + let totalBytes = 0 + const stream = createReadStream(join(rootDir, PLUGIN_MANIFEST_FILENAME)) + for await (const chunk of stream) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > PLUGIN_MANIFEST_MAX_BYTES) { + throw new Error(`${PLUGIN_MANIFEST_FILENAME} exceeds ${PLUGIN_MANIFEST_MAX_BYTES} bytes`) + } + chunks.push(bytes) + } + return Buffer.concat(chunks, totalBytes).toString('utf8') +} diff --git a/src/main/plugins/plugin-marketplace-error-message.ts b/src/main/plugins/plugin-marketplace-error-message.ts new file mode 100644 index 00000000000..d829036926d --- /dev/null +++ b/src/main/plugins/plugin-marketplace-error-message.ts @@ -0,0 +1,3 @@ +export function pluginMarketplaceErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/src/main/plugins/plugin-marketplace-fetch.ts b/src/main/plugins/plugin-marketplace-fetch.ts new file mode 100644 index 00000000000..d035841eb84 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-fetch.ts @@ -0,0 +1,63 @@ +import { createReadStream } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { + PLUGIN_MARKETPLACE_FILENAME, + pluginMarketplaceSchema, + type PluginMarketplace +} from '../../shared/plugins/plugin-marketplace' +import { checkoutPluginGitSource } from './plugin-git-repository' +import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' + +const MARKETPLACE_INDEX_MAX_BYTES = 16 * 1024 * 1024 + +export type PluginMarketplaceFetchResult = { + marketplaceCommit: string + marketplace: PluginMarketplace +} + +/** Fetches a marketplace through system Git so private repositories use the + * same SSH agent and credential helpers as every other Orca Git operation. */ +export async function fetchPluginMarketplace( + source: PluginMarketplaceRegisteredSource +): Promise { + const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-')) + try { + const marketplaceCommit = await checkoutPluginGitSource({ + url: source.source.url, + ref: source.source.ref, + destination: stagingDirectory, + workingDirectory: tmpdir() + }) + const marketplace = await readPluginMarketplaceIndex(stagingDirectory) + return { marketplaceCommit, marketplace } + } finally { + await rm(stagingDirectory, { recursive: true, force: true }) + } +} + +export async function readPluginMarketplaceIndex( + rootDirectory: string +): Promise { + const path = join(rootDirectory, PLUGIN_MARKETPLACE_FILENAME) + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(path)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > MARKETPLACE_INDEX_MAX_BYTES) { + throw new Error(`${PLUGIN_MARKETPLACE_FILENAME} exceeds its size limit`) + } + chunks.push(bytes) + } + try { + return pluginMarketplaceSchema.parse( + JSON.parse(Buffer.concat(chunks, totalBytes).toString('utf8')) + ) + } catch (error) { + throw new Error( + `invalid ${PLUGIN_MARKETPLACE_FILENAME}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} diff --git a/src/main/plugins/plugin-marketplace-installer.test.ts b/src/main/plugins/plugin-marketplace-installer.test.ts new file mode 100644 index 00000000000..589a90699ef --- /dev/null +++ b/src/main/plugins/plugin-marketplace-installer.test.ts @@ -0,0 +1,198 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PluginMarketplace } from '../../shared/plugins/plugin-marketplace' +import { readPluginLockfile } from './plugin-install' +import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' +import { PluginMarketplaceService } from './plugin-marketplace-service' + +const git = vi.hoisted(() => ({ + checkout: vi.fn(), + version: '1.0.0', + publisher: 'community', + id: 'notes', + commit: 'a'.repeat(40), + payload: 'first' +})) + +vi.mock('./plugin-git-repository', () => ({ + checkoutPluginGitSource: git.checkout +})) + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-installer-')) + roots.push(root) + return root +} + +function marketplace(): PluginMarketplace { + return { + name: 'Community', + owner: 'community', + plugins: [ + { + id: 'community.notes', + source: { + kind: 'git', + url: 'https://github.com/community/notes.git', + ref: 'stable' + }, + categories: ['productivity'] + } + ] + } +} + +async function writeCurrentPlugin(destination: string): Promise { + await mkdir(destination, { recursive: true }) + await writeFile( + join(destination, 'orca-plugin.json'), + JSON.stringify({ + manifestVersion: 1, + id: git.id, + publisher: git.publisher, + name: 'Notes', + version: git.version, + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + capabilities: [] + }) + ) + await writeFile(join(destination, 'payload.txt'), git.payload) +} + +async function setup(): Promise<{ + root: string + marketplace: PluginMarketplaceService + installer: PluginMarketplaceInstaller + sourceId: string +}> { + const root = await tempRoot() + const service = new PluginMarketplaceService({ + pluginsDataDir: join(root, 'plugins-data'), + fetcher: async () => ({ marketplaceCommit: 'f'.repeat(40), marketplace: marketplace() }) + }) + const added = await service.addSource({ + kind: 'git', + url: 'https://github.com/community/plugins.git', + ref: 'main' + }) + return { + root, + marketplace: service, + installer: new PluginMarketplaceInstaller({ + marketplace: service, + userDataPath: root, + hostVersion: '1.4.0' + }), + sourceId: added.id + } +} + +beforeEach(() => { + git.version = '1.0.0' + git.publisher = 'community' + git.id = 'notes' + git.commit = 'a'.repeat(40) + git.payload = 'first' + git.checkout.mockReset() + git.checkout.mockImplementation(async ({ destination }: { destination: string }) => { + await writeCurrentPlugin(destination) + return git.commit + }) +}) + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginMarketplaceInstaller', () => { + it('previews exact validated bytes and records marketplace provenance on install', async () => { + const { root, installer, sourceId } = await setup() + + const preview = await installer.preview(sourceId, 'community.notes') + expect(preview).toMatchObject({ + pluginKey: 'community.notes', + resolvedCommit: 'a'.repeat(40), + marketplaceCommit: 'f'.repeat(40), + manifest: { version: '1.0.0' } + }) + const result = await installer.install(preview) + + if (!result.ok) { + throw new Error(result.error) + } + expect(result).toMatchObject({ ok: true, resolvedCommit: 'a'.repeat(40) }) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']?.source).toEqual({ + kind: 'marketplace', + marketplace: { + url: 'https://github.com/community/plugins.git', + ref: 'main', + resolvedCommit: 'f'.repeat(40) + }, + plugin: { + url: 'https://github.com/community/notes.git', + ref: 'stable' + } + }) + }) + + it('requires a fresh review when the plugin ref moves after preview', async () => { + const { root, installer, sourceId } = await setup() + const preview = await installer.preview(sourceId, 'community.notes') + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + + await expect(installer.install(preview)).resolves.toEqual({ + ok: false, + error: 'plugin source changed after preview; review the update again' + }) + await expect(readFile(join(root, 'plugins', 'plugins.lock.json'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('rejects a source whose manifest identity differs from its listing', async () => { + const { installer, sourceId } = await setup() + git.publisher = 'attacker' + + await expect(installer.preview(sourceId, 'community.notes')).rejects.toThrow( + 'attacker.notes does not match marketplace listing community.notes' + ) + }) + + it('updates from recorded marketplace provenance and rolls back one immutable version', async () => { + const { root, installer, sourceId } = await setup() + const firstPreview = await installer.preview(sourceId, 'community.notes') + const firstInstall = await installer.install(firstPreview) + expect(firstInstall.ok).toBe(true) + if (!firstInstall.ok) { + return + } + + git.commit = 'b'.repeat(40) + git.version = '2.0.0' + git.payload = 'second' + const updatePreview = await installer.previewInstalledUpdate('community.notes') + expect(updatePreview).toMatchObject({ resolvedCommit: 'b'.repeat(40) }) + await expect(installer.install(updatePreview)).resolves.toMatchObject({ + ok: true, + version: '2.0.0' + }) + + await expect(installer.rollback('community.notes')).resolves.toMatchObject({ + ok: true, + version: '1.0.0', + contentHash: firstInstall.contentHash + }) + const lock = await readPluginLockfile(join(root, 'plugins')) + expect(lock.plugins['community.notes']).toMatchObject({ + version: '1.0.0', + resolvedCommit: 'a'.repeat(40) + }) + }) +}) diff --git a/src/main/plugins/plugin-marketplace-installer.ts b/src/main/plugins/plugin-marketplace-installer.ts new file mode 100644 index 00000000000..179e687682c --- /dev/null +++ b/src/main/plugins/plugin-marketplace-installer.ts @@ -0,0 +1,164 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { PluginManifest } from '../../shared/plugins/plugin-manifest' +import { getUserPluginsDir } from './plugin-discovery' +import { checkoutPluginGitSource } from './plugin-git-repository' +import { + installPluginFromMarketplace, + readPluginLockfile, + rollbackInstalledPlugin, + type PluginInstallResult +} from './plugin-install' +import { inspectPluginInstallTree } from './plugin-install-staging' +import type { + PluginMarketplaceListing, + PluginMarketplaceService +} from './plugin-marketplace-service' +import { marketplaceSourceId } from './plugin-marketplace-store' + +export type PluginMarketplaceInstallPreview = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceListing['source'] + resolvedCommit: string + contentHash: string + consentFingerprint: string + manifest: PluginManifest + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + +export type PluginMarketplacePreviewIdentity = Pick< + PluginMarketplaceInstallPreview, + 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' +> + +export class PluginMarketplaceInstaller { + private readonly marketplace: PluginMarketplaceService + private readonly userDataPath: string + private readonly hostVersion: string + private readonly blockedPluginReason: (pluginKey: string) => string | null + + constructor(options: { + marketplace: PluginMarketplaceService + userDataPath: string + hostVersion: string + blockedPluginReason?: (pluginKey: string) => string | null + }) { + this.marketplace = options.marketplace + this.userDataPath = options.userDataPath + this.hostVersion = options.hostVersion + this.blockedPluginReason = options.blockedPluginReason ?? (() => null) + } + + async preview( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const listing = await this.requireListing(marketplaceSourceId, pluginKey) + const stagingDirectory = await mkdtemp(join(tmpdir(), 'orca-plugin-marketplace-preview-')) + try { + const resolvedCommit = await checkoutPluginGitSource({ + url: listing.source.url, + ref: listing.source.ref, + destination: stagingDirectory, + workingDirectory: tmpdir() + }) + const inspection = await inspectPluginInstallTree({ + rootDir: stagingDirectory, + hostVersion: this.hostVersion, + expectedPluginKey: pluginKey + }) + if (!inspection.ok) { + throw new Error(inspection.error) + } + return { + marketplaceSourceId, + marketplaceName: listing.marketplaceName, + marketplaceOwner: listing.marketplaceOwner, + marketplaceCommit: listing.marketplaceCommit, + pluginKey, + source: listing.source, + resolvedCommit, + contentHash: inspection.contentHash, + consentFingerprint: inspection.consentFingerprint, + manifest: inspection.manifest, + official: listing.official, + bundled: listing.bundled, + ...(listing.blockedByKillList ? { blockedByKillList: listing.blockedByKillList } : {}) + } + } finally { + await rm(stagingDirectory, { recursive: true, force: true }) + } + } + + async install(preview: PluginMarketplacePreviewIdentity): Promise { + const listing = await this.requireListing(preview.marketplaceSourceId, preview.pluginKey) + const blockedReason = + listing.blockedByKillList?.reason ?? this.blockedPluginReason(preview.pluginKey) + if (blockedReason) { + return { ok: false, error: `plugin is blocked by Orca's safety list: ${blockedReason}` } + } + if (listing.marketplaceCommit !== preview.marketplaceCommit) { + return { ok: false, error: 'marketplace changed after preview; review the plugin again' } + } + const sourceState = (await this.marketplace.listSources()).find( + (source) => source.id === preview.marketplaceSourceId + ) + if (!sourceState) { + return { ok: false, error: 'marketplace source is no longer configured' } + } + return installPluginFromMarketplace({ + pluginsDir: getUserPluginsDir(this.userDataPath), + hostVersion: this.hostVersion, + expectedPluginKey: preview.pluginKey, + expectedResolvedCommit: preview.resolvedCommit, + marketplace: { + url: sourceState.source.url, + ref: sourceState.source.ref, + resolvedCommit: preview.marketplaceCommit + }, + plugin: { url: listing.source.url, ref: listing.source.ref }, + blockedPluginReason: this.blockedPluginReason + }) + } + + async previewInstalledUpdate(pluginKey: string): Promise { + const lock = await readPluginLockfile(getUserPluginsDir(this.userDataPath)) + const entry = lock.plugins[pluginKey] + if (!entry || entry.source.kind !== 'marketplace') { + throw new Error(`plugin ${pluginKey} was not installed from a marketplace`) + } + const sourceId = marketplaceSourceId({ + kind: 'git', + url: entry.source.marketplace.url, + ref: entry.source.marketplace.ref + }) + return this.preview(sourceId, pluginKey) + } + + async rollback(pluginKey: string): Promise { + return rollbackInstalledPlugin({ + pluginsDir: getUserPluginsDir(this.userDataPath), + pluginKey, + hostVersion: this.hostVersion, + blockedPluginReason: this.blockedPluginReason + }) + } + + private async requireListing( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const listing = await this.marketplace.findPlugin(marketplaceSourceId, pluginKey) + if (!listing) { + throw new Error(`plugin ${pluginKey} is not listed by marketplace ${marketplaceSourceId}`) + } + return listing + } +} diff --git a/src/main/plugins/plugin-marketplace-projection.ts b/src/main/plugins/plugin-marketplace-projection.ts new file mode 100644 index 00000000000..c411801d8d9 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-projection.ts @@ -0,0 +1,33 @@ +import type { + PluginMarketplaceEntry, + PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' + +export type PluginMarketplaceSourceState = { + id: string + source: PluginMarketplaceGitSource + addedAt: number + marketplace: { + name: string + owner: string + resolvedCommit: string + fetchedAt: number + } | null + stale: boolean + official: boolean + error?: string +} + +export type PluginMarketplaceListing = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceEntry['source'] + description?: string + categories: string[] + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} diff --git a/src/main/plugins/plugin-marketplace-provenance.ts b/src/main/plugins/plugin-marketplace-provenance.ts new file mode 100644 index 00000000000..fbef2ce69f8 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-provenance.ts @@ -0,0 +1,27 @@ +import { + OFFICIAL_MARKETPLACE_OWNER, + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + isReservedPluginIdentity +} from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch' +import type { PluginMarketplaceRegisteredSource } from './plugin-marketplace-store' + +export function validateMarketplaceProvenance( + source: PluginMarketplaceRegisteredSource, + fetched: PluginMarketplaceFetchResult +): void { + if ( + isOfficialMarketplaceGitSource(source.source.url) && + fetched.marketplace.owner.toLowerCase() !== OFFICIAL_MARKETPLACE_OWNER + ) { + throw new Error('official marketplace metadata has an unexpected owner') + } + for (const entry of fetched.marketplace.plugins) { + if (isReservedPluginIdentity(entry.id) && !isOfficialOrganizationGitSource(entry.source.url)) { + throw new Error( + `reserved plugin identity ${entry.id} must resolve to the stablyai organization` + ) + } + } +} diff --git a/src/main/plugins/plugin-marketplace-service.test.ts b/src/main/plugins/plugin-marketplace-service.test.ts new file mode 100644 index 00000000000..38c39a79ce0 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-service.test.ts @@ -0,0 +1,336 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { + PluginMarketplace, + PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' +import { OFFICIAL_MARKETPLACE_GIT_SOURCE } from '../../shared/plugins/plugin-marketplace' +import type { PluginMarketplaceFetchResult } from './plugin-marketplace-fetch' +import { PluginMarketplaceService } from './plugin-marketplace-service' +import { + marketplaceSourceId, + PLUGIN_MARKETPLACE_SOURCE_LIMIT, + PluginMarketplaceStore, + type PluginMarketplaceRegisteredSource +} from './plugin-marketplace-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-service-')) + roots.push(root) + return root +} + +function source(url = 'https://github.com/community/plugins.git'): PluginMarketplaceGitSource { + return { kind: 'git', url, ref: 'main' } +} + +function marketplace( + name = 'Community', + pluginKey = 'community.notes', + pluginUrl = 'https://github.com/community/notes.git' +): PluginMarketplace { + return { + name, + owner: name.toLowerCase(), + plugins: [ + { + id: pluginKey, + source: { kind: 'git', url: pluginUrl, ref: 'v1' }, + description: 'Notes for active worktrees.', + categories: ['productivity'] + } + ] + } +} + +function fetched(value = marketplace(), commit = 'a'.repeat(40)): PluginMarketplaceFetchResult { + return { marketplaceCommit: commit, marketplace: value } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginMarketplaceService', () => { + it('fetches before registration, then serves browse data from the local snapshot', async () => { + const fetcher = vi + .fn< + (registration: PluginMarketplaceRegisteredSource) => Promise + >() + .mockResolvedValue(fetched()) + const service = new PluginMarketplaceService({ pluginsDataDir: await tempRoot(), fetcher }) + + const added = await service.addSource(source()) + + expect(added).toMatchObject({ marketplace: { name: 'Community' }, stale: false }) + expect(fetcher).toHaveBeenCalledTimes(1) + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ + marketplaceSourceId: added.id, + pluginKey: 'community.notes', + official: false, + bundled: false + }) + ]) + await service.listSources() + expect(fetcher).toHaveBeenCalledTimes(1) + }) + + it('keeps and labels the last valid snapshot when a refresh is offline', async () => { + const fetcher = vi + .fn< + (registration: PluginMarketplaceRegisteredSource) => Promise + >() + .mockResolvedValueOnce(fetched()) + .mockRejectedValueOnce(new Error('offline')) + const service = new PluginMarketplaceService({ pluginsDataDir: await tempRoot(), fetcher }) + const added = await service.addSource(source()) + + await expect(service.refreshSource(added.id)).resolves.toMatchObject({ + marketplace: { name: 'Community', resolvedCommit: 'a'.repeat(40) }, + stale: true, + error: 'offline' + }) + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ pluginKey: 'community.notes' }) + ]) + }) + + it('atomically replaces the cache only after a valid refreshed index', async () => { + const fetcher = vi + .fn< + (registration: PluginMarketplaceRegisteredSource) => Promise + >() + .mockResolvedValueOnce(fetched()) + .mockResolvedValueOnce(fetched(marketplace('Updated'), 'b'.repeat(40))) + const root = await tempRoot() + const service = new PluginMarketplaceService({ pluginsDataDir: root, fetcher }) + const added = await service.addSource(source()) + + await expect(service.refreshSource(added.id)).resolves.toMatchObject({ + marketplace: { name: 'Updated', resolvedCommit: 'b'.repeat(40) }, + stale: false + }) + await expect( + new PluginMarketplaceService({ pluginsDataDir: root, fetcher }).listPlugins() + ).resolves.toEqual([expect.objectContaining({ marketplaceName: 'Updated' })]) + }) + + it('does not persist a source whose first fetch fails', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => { + throw new Error('authentication failed') + } + }) + + await expect(service.addSource(source())).rejects.toThrow('authentication failed') + await expect(service.listSources()).resolves.toEqual([]) + }) + + it('rejects reserved identities outside the official organization', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => + fetched( + marketplace('Attack', 'community.orca-secrets', 'https://github.com/attacker/x.git') + ) + }) + + await expect(service.addSource(source())).rejects.toThrow( + 'reserved plugin identity community.orca-secrets' + ) + await expect(service.listSources()).resolves.toEqual([]) + }) + + it('derives the Official badge only from the canonical marketplace and source organization', async () => { + const officialMarketplace: PluginMarketplace = { + name: 'Orca Plugins', + owner: 'stablyai', + plugins: [ + { + id: 'stablyai.orca-shortcuts', + source: { + kind: 'git', + url: 'git@github.com:stablyai/orca-shortcuts.git', + ref: 'main' + }, + categories: ['keybindings'] + } + ] + } + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => fetched(officialMarketplace) + }) + + await service.addSource(source('https://github.com/stablyai/orca-plugins.git')) + + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ pluginKey: 'stablyai.orca-shortcuts', official: true }) + ]) + }) + + it('hides listings whose contribution kind this build no longer supports', async () => { + const mixed: PluginMarketplace = { + name: 'Community', + owner: 'community', + plugins: [ + { + id: 'community.midnight', + source: { kind: 'git', url: 'https://github.com/community/midnight.git', ref: 'v1' }, + categories: ['themes', 'official'] + }, + { + id: 'community.recipes', + source: { kind: 'git', url: 'https://github.com/community/recipes.git', ref: 'v1' }, + categories: ['vm-recipes'] + } + ] + } + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => fetched(mixed) + }) + + const added = await service.addSource(source()) + + // The theme pack is filtered out; only the supported recipe listing remains. + await expect(service.listPlugins()).resolves.toEqual([ + expect.objectContaining({ pluginKey: 'community.recipes' }) + ]) + + // Preview and install resolve by key, so an unsupported pack must be + // unreachable that way too — otherwise the dead install just moves later. + await expect(service.findPlugin(added.id, 'community.midnight')).resolves.toBeNull() + await expect(service.findPlugin(added.id, 'community.recipes')).resolves.toEqual( + expect.objectContaining({ pluginKey: 'community.recipes' }) + ) + }) + + it('seeds the official marketplace once and keeps it configured across restarts', async () => { + const root = await tempRoot() + const officialMarketplace = marketplace( + 'Orca Plugins', + 'stablyai.orca-notes', + 'https://github.com/stablyai/orca-notes.git' + ) + officialMarketplace.owner = 'stablyai' + const fetcher = vi.fn(async () => fetched(officialMarketplace)) + const first = new PluginMarketplaceService({ pluginsDataDir: root, fetcher }) + + await expect(first.seedOfficialSource()).resolves.toMatchObject({ + official: true, + marketplace: { name: 'Orca Plugins' } + }) + await expect(first.seedOfficialSource()).resolves.toMatchObject({ official: true }) + expect(fetcher).toHaveBeenCalledTimes(1) + + const restarted = new PluginMarketplaceService({ pluginsDataDir: root, fetcher }) + await expect(restarted.seedOfficialSource()).resolves.toMatchObject({ official: true }) + expect(fetcher).toHaveBeenCalledTimes(1) + await expect(restarted.listSources()).resolves.toHaveLength(1) + }) + + it('persists an offline official source for a later refresh and does not remove it', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => { + throw new Error('offline') + } + }) + + const seeded = await service.seedOfficialSource() + + expect(seeded).toMatchObject({ official: true, stale: true, marketplace: null }) + await expect(service.removeSource(seeded.id)).rejects.toThrow('cannot be removed') + await expect(service.listSources()).resolves.toEqual([seeded]) + }) + + it('keeps reads usable and allows retry after official seeding rejects', async () => { + const registered: PluginMarketplaceRegisteredSource = { + id: marketplaceSourceId(OFFICIAL_MARKETPLACE_GIT_SOURCE), + source: OFFICIAL_MARKETPLACE_GIT_SOURCE, + addedAt: 1 + } + const officialMarketplace = marketplace( + 'Orca Plugins', + 'stablyai.orca-notes', + 'https://github.com/stablyai/orca-notes.git' + ) + officialMarketplace.owner = 'stablyai' + const listSources = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error('source store temporarily unavailable')) + .mockResolvedValue([registered]) + const store = { + listSources, + readSnapshot: vi.fn().mockResolvedValue(null), + writeSnapshot: vi.fn(async ({ source: snapshotSource, ...snapshot }) => ({ + schemaVersion: 1 as const, + sourceId: snapshotSource.id, + source: snapshotSource.source, + fetchedAt: 2, + ...snapshot + })) + } as unknown as PluginMarketplaceStore + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + store, + fetcher: async () => fetched(officialMarketplace) + }) + + await expect(service.seedOfficialSource()).rejects.toThrow('temporarily unavailable') + await expect(service.listSources()).resolves.toEqual([ + expect.objectContaining({ id: registered.id, official: true }) + ]) + await expect(service.seedOfficialSource()).resolves.toMatchObject({ + marketplace: { name: 'Orca Plugins' }, + official: true + }) + }) + + it('recovers the managed source after a full existing store frees a slot', async () => { + const root = await tempRoot() + const store = new PluginMarketplaceStore(root) + const registrations = await Promise.all( + Array.from({ length: PLUGIN_MARKETPLACE_SOURCE_LIMIT }, (_, index) => + store.addSource(source(`https://example.com/community-${index}.git`), index + 1) + ) + ) + const officialMarketplace = marketplace( + 'Orca Plugins', + 'stablyai.orca-notes', + 'https://github.com/stablyai/orca-notes.git' + ) + officialMarketplace.owner = 'stablyai' + const service = new PluginMarketplaceService({ + pluginsDataDir: root, + store, + fetcher: async () => fetched(officialMarketplace) + }) + + await expect(service.seedOfficialSource()).rejects.toThrow('source limit') + await expect(service.removeSource(registrations[0]!.id)).resolves.toBe(true) + + const sources = await service.listSources() + expect(sources).toHaveLength(PLUGIN_MARKETPLACE_SOURCE_LIMIT) + expect(sources).toContainEqual(expect.objectContaining({ official: true })) + }) + + it('removes source metadata and browse listings together', async () => { + const service = new PluginMarketplaceService({ + pluginsDataDir: await tempRoot(), + fetcher: async () => fetched() + }) + const added = await service.addSource(source()) + + await expect(service.removeSource(added.id)).resolves.toBe(true) + await expect(service.listSources()).resolves.toEqual([]) + await expect(service.listPlugins()).resolves.toEqual([]) + }) +}) diff --git a/src/main/plugins/plugin-marketplace-service.ts b/src/main/plugins/plugin-marketplace-service.ts new file mode 100644 index 00000000000..cdee5bf9721 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-service.ts @@ -0,0 +1,320 @@ +import { + OFFICIAL_MARKETPLACE_OWNER, + OFFICIAL_MARKETPLACE_GIT_SOURCE, + isOfficialMarketplaceGitSource, + isOfficialOrganizationGitSource, + isOfficialPluginIdentity, + isMarketplaceListingSupported, + pluginMarketplaceGitSourceSchema, + type PluginMarketplaceEntry, + type PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' +import { + fetchPluginMarketplace, + type PluginMarketplaceFetchResult +} from './plugin-marketplace-fetch' +import { + marketplaceSourceId, + PluginMarketplaceStore, + type PluginMarketplaceCachedSnapshot, + type PluginMarketplaceRegisteredSource +} from './plugin-marketplace-store' +import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list' +import { validateMarketplaceProvenance } from './plugin-marketplace-provenance' +import { pluginMarketplaceErrorMessage } from './plugin-marketplace-error-message' +import type { + PluginMarketplaceListing, + PluginMarketplaceSourceState +} from './plugin-marketplace-projection' +export type { + PluginMarketplaceListing, + PluginMarketplaceSourceState +} from './plugin-marketplace-projection' + +type MarketplaceFetcher = ( + source: PluginMarketplaceRegisteredSource +) => Promise + +export class PluginMarketplaceService { + private readonly store: PluginMarketplaceStore + private readonly fetcher: MarketplaceFetcher + private readonly getKillListEntry: (pluginKey: string) => PluginKillListEntry | null + private readonly refreshChains = new Map>() + private readonly sourceErrors = new Map() + private officialSeedPromise: Promise | null = null + private officialSeedRequested = false + + constructor(options: { + pluginsDataDir: string + fetcher?: MarketplaceFetcher + store?: PluginMarketplaceStore + getKillListEntry?: (pluginKey: string) => PluginKillListEntry | null + }) { + this.store = options.store ?? new PluginMarketplaceStore(options.pluginsDataDir) + this.fetcher = options.fetcher ?? fetchPluginMarketplace + this.getKillListEntry = options.getKillListEntry ?? (() => null) + } + + async listSources(): Promise { + await this.waitForOfficialSeed() + const sources = await this.store.listSources() + return Promise.all( + sources.map(async (source) => { + try { + const error = this.sourceErrors.get(source.id) + return this.stateFromSnapshot( + source, + await this.store.readSnapshot(source.id), + Boolean(error), + error + ) + } catch (error) { + return this.stateFromSnapshot(source, null, true, pluginMarketplaceErrorMessage(error)) + } + }) + ) + } + + async addSource(source: PluginMarketplaceGitSource): Promise { + const parsedSource = pluginMarketplaceGitSourceSchema.parse(source) + const sourceId = marketplaceSourceId(parsedSource) + const existing = (await this.store.listSources()).find((candidate) => candidate.id === sourceId) + const candidate: PluginMarketplaceRegisteredSource = existing ?? { + id: sourceId, + source: parsedSource, + addedAt: Date.now() + } + const fetched = await this.fetchAndValidate(candidate) + const registered = existing ?? (await this.store.addSource(parsedSource, candidate.addedAt)) + try { + const snapshot = await this.store.writeSnapshot({ source: registered, ...fetched }) + this.sourceErrors.delete(registered.id) + return this.stateFromSnapshot(registered, snapshot, false) + } catch (error) { + if (!existing) { + await this.store.removeSource(registered.id).catch(() => undefined) + } + throw error + } + } + + async removeSource(sourceId: string): Promise { + const source = (await this.store.listSources()).find((candidate) => candidate.id === sourceId) + if (source && isOfficialMarketplaceGitSource(source.source.url)) { + throw new Error('the official marketplace is managed by Orca and cannot be removed') + } + const removed = await this.store.removeSource(sourceId) + if (removed) { + this.sourceErrors.delete(sourceId) + if (this.officialSeedRequested) { + // Why: an existing profile may already occupy every source slot. Once + // the user frees one, recover the managed source without a restart. + await this.seedOfficialSource().catch(() => undefined) + } + } + return removed + } + + seedOfficialSource(): Promise { + this.officialSeedRequested = true + if (!this.officialSeedPromise) { + const seed = this.performOfficialSeed() + this.officialSeedPromise = seed + void seed.catch(() => { + if (this.officialSeedPromise === seed) { + // Why: a transient store failure or full source list must not poison + // every marketplace read or prevent a later recovery attempt. + this.officialSeedPromise = null + } + }) + } + return this.officialSeedPromise + } + + async refreshSource(sourceId: string): Promise { + const previous = this.refreshChains.get(sourceId) ?? Promise.resolve(null) + const refresh = previous.catch(() => null).then(() => this.performRefresh(sourceId)) + this.refreshChains.set(sourceId, refresh) + try { + return await refresh + } finally { + if (this.refreshChains.get(sourceId) === refresh) { + this.refreshChains.delete(sourceId) + } + } + } + + async refreshAll(): Promise { + await this.waitForOfficialSeed() + const sources = await this.store.listSources() + return Promise.all(sources.map((source) => this.refreshSource(source.id))) + } + + async listPlugins(): Promise { + await this.waitForOfficialSeed() + const states = await this.listSnapshots() + return states + .flatMap(({ source, snapshot }) => + snapshot.marketplace.plugins + // Why: hide packs whose contribution kind this build no longer + // supports (themes/icons/skills) so users never reach a dead install. + .filter((entry) => isMarketplaceListingSupported(entry.categories)) + .map((entry) => this.listingFromEntry(source, snapshot, entry)) + ) + .sort((left, right) => + `${left.pluginKey}\0${left.marketplaceSourceId}`.localeCompare( + `${right.pluginKey}\0${right.marketplaceSourceId}` + ) + ) + } + + async findPlugin( + marketplaceSourceId: string, + pluginKey: string + ): Promise { + const source = (await this.store.listSources()).find( + (candidate) => candidate.id === marketplaceSourceId + ) + if (!source) { + return null + } + const snapshot = await this.store.readSnapshot(source.id) + // Why: preview and install resolve listings through here, so an unsupported + // pack must be unreachable by key too — hiding only the catalog card would + // move the dead install one click later instead of removing it. + const entry = snapshot?.marketplace.plugins.find( + (plugin) => plugin.id === pluginKey && isMarketplaceListingSupported(plugin.categories) + ) + return snapshot && entry ? this.listingFromEntry(source, snapshot, entry) : null + } + + private async performRefresh(sourceId: string): Promise { + const source = (await this.store.listSources()).find((candidate) => candidate.id === sourceId) + if (!source) { + throw new Error(`unknown marketplace source: ${sourceId}`) + } + try { + const fetched = await this.fetchAndValidate(source) + const snapshot = await this.store.writeSnapshot({ source, ...fetched }) + this.sourceErrors.delete(source.id) + return this.stateFromSnapshot(source, snapshot, false) + } catch (error) { + const cached = await this.store.readSnapshot(source.id).catch(() => null) + if (!cached) { + throw error + } + const message = pluginMarketplaceErrorMessage(error) + this.sourceErrors.set(source.id, message) + return this.stateFromSnapshot(source, cached, true, message) + } + } + + private async performOfficialSeed(): Promise { + const sources = await this.store.listSources() + const existing = sources.find((source) => isOfficialMarketplaceGitSource(source.source.url)) + const source = + existing ?? (await this.store.addSource(OFFICIAL_MARKETPLACE_GIT_SOURCE, Date.now())) + const snapshot = await this.store.readSnapshot(source.id).catch(() => null) + if (snapshot) { + return this.stateFromSnapshot(source, snapshot, false) + } + try { + return await this.performRefresh(source.id) + } catch (error) { + // Why: the official source remains configured offline so a later manual + // or startup refresh can recover without asking the user for its URL. + const message = pluginMarketplaceErrorMessage(error) + this.sourceErrors.set(source.id, message) + return this.stateFromSnapshot(source, null, true, message) + } + } + + private async waitForOfficialSeed(): Promise { + await this.officialSeedPromise?.catch(() => undefined) + } + + private async fetchAndValidate( + source: PluginMarketplaceRegisteredSource + ): Promise { + const fetched = await this.fetcher(source) + validateMarketplaceProvenance(source, fetched) + return fetched + } + + private async listSnapshots(): Promise< + { source: PluginMarketplaceRegisteredSource; snapshot: PluginMarketplaceCachedSnapshot }[] + > { + const sources = await this.store.listSources() + const snapshots = await Promise.all( + sources.map(async (source) => ({ + source, + snapshot: await this.store.readSnapshot(source.id) + })) + ) + return snapshots.filter( + ( + candidate + ): candidate is { + source: PluginMarketplaceRegisteredSource + snapshot: PluginMarketplaceCachedSnapshot + } => candidate.snapshot !== null + ) + } + + private listingFromEntry( + source: PluginMarketplaceRegisteredSource, + snapshot: PluginMarketplaceCachedSnapshot, + entry: PluginMarketplaceEntry + ): PluginMarketplaceListing { + const official = + isOfficialMarketplaceGitSource(source.source.url) && + snapshot.marketplace.owner.toLowerCase() === OFFICIAL_MARKETPLACE_OWNER && + isOfficialPluginIdentity(entry.id) && + isOfficialOrganizationGitSource(entry.source.url) + const blocked = this.getKillListEntry(entry.id) + return { + marketplaceSourceId: source.id, + marketplaceName: snapshot.marketplace.name, + marketplaceOwner: snapshot.marketplace.owner, + marketplaceCommit: snapshot.marketplaceCommit, + pluginKey: entry.id, + source: entry.source, + ...(entry.description ? { description: entry.description } : {}), + categories: entry.categories, + official, + bundled: false, + ...(blocked + ? { + blockedByKillList: { + reason: blocked.reason, + ...(blocked.advisoryUrl ? { advisoryUrl: blocked.advisoryUrl } : {}) + } + } + : {}) + } + } + + private stateFromSnapshot( + source: PluginMarketplaceRegisteredSource, + snapshot: PluginMarketplaceCachedSnapshot | null, + stale: boolean, + error?: string + ): PluginMarketplaceSourceState { + return { + id: source.id, + source: source.source, + addedAt: source.addedAt, + marketplace: snapshot + ? { + name: snapshot.marketplace.name, + owner: snapshot.marketplace.owner, + resolvedCommit: snapshot.marketplaceCommit, + fetchedAt: snapshot.fetchedAt + } + : null, + stale, + official: isOfficialMarketplaceGitSource(source.source.url), + ...(error ? { error } : {}) + } + } +} diff --git a/src/main/plugins/plugin-marketplace-store.test.ts b/src/main/plugins/plugin-marketplace-store.test.ts new file mode 100644 index 00000000000..4607037334b --- /dev/null +++ b/src/main/plugins/plugin-marketplace-store.test.ts @@ -0,0 +1,85 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' +import { marketplaceSourceId, PluginMarketplaceStore } from './plugin-marketplace-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-marketplace-store-')) + roots.push(root) + return root +} + +function source(ref = 'main'): PluginMarketplaceGitSource { + return { kind: 'git', url: 'https://github.com/community/plugins.git', ref } +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginMarketplaceStore', () => { + it('persists bounded source registrations under a deterministic opaque id', async () => { + const root = await tempRoot() + const first = new PluginMarketplaceStore(root) + const registered = await first.addSource(source(), 123) + + expect(registered).toEqual({ + id: marketplaceSourceId(source()), + source: source(), + addedAt: 123 + }) + await expect(new PluginMarketplaceStore(root).listSources()).resolves.toEqual([registered]) + await expect(first.addSource(source(), 999)).resolves.toEqual(registered) + }) + + it('atomically publishes a strict cached snapshot and removes it with its source', async () => { + const root = await tempRoot() + const store = new PluginMarketplaceStore(root) + const registered = await store.addSource(source(), 123) + const snapshot = await store.writeSnapshot({ + source: registered, + marketplaceCommit: 'a'.repeat(40), + fetchedAt: 456, + marketplace: { + name: 'Community', + owner: 'community', + plugins: [ + { + id: 'community.theme', + source: { + kind: 'git', + url: 'https://github.com/community/theme.git', + ref: 'v1' + }, + categories: ['themes'] + } + ] + } + }) + + await expect(new PluginMarketplaceStore(root).readSnapshot(registered.id)).resolves.toEqual( + snapshot + ) + expect( + (await readdir(join(root, 'marketplaces', 'snapshots'))).filter((entry) => + entry.endsWith('.tmp') + ) + ).toEqual([]) + await expect(store.removeSource(registered.id)).resolves.toBe(true) + await expect(store.readSnapshot(registered.id)).resolves.toBeNull() + }) + + it('keeps distinct refs as distinct marketplace sources', () => { + expect(marketplaceSourceId(source('main'))).not.toBe(marketplaceSourceId(source('stable'))) + }) + + it('rejects path-like source ids before reading or deleting cache files', async () => { + const store = new PluginMarketplaceStore(await tempRoot()) + await expect(store.readSnapshot('../outside')).rejects.toThrow() + await expect(store.removeSource('../outside')).rejects.toThrow() + }) +}) diff --git a/src/main/plugins/plugin-marketplace-store.ts b/src/main/plugins/plugin-marketplace-store.ts new file mode 100644 index 00000000000..437f6148f46 --- /dev/null +++ b/src/main/plugins/plugin-marketplace-store.ts @@ -0,0 +1,212 @@ +import { createHash } from 'node:crypto' +import { createReadStream } from 'node:fs' +import { mkdir, rm } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { z } from 'zod' +import { PLUGIN_COMMIT_PATTERN } from '../../shared/plugins/plugin-install-lockfile' +import { + pluginMarketplaceGitSourceSchema, + pluginMarketplaceSchema, + type PluginMarketplace, + type PluginMarketplaceGitSource +} from '../../shared/plugins/plugin-marketplace' +import { writePluginFileAtomically } from './plugin-atomic-file-write' + +export const PLUGIN_MARKETPLACE_SOURCE_LIMIT = 64 +export const PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN = /^[0-9a-f]{32}$/ + +const sourceIdSchema = z.string().regex(PLUGIN_MARKETPLACE_SOURCE_ID_PATTERN) +const registeredSourceSchema = z.strictObject({ + id: sourceIdSchema, + source: pluginMarketplaceGitSourceSchema, + addedAt: z.number().finite().nonnegative() +}) +const sourceFileSchema = z.strictObject({ + schemaVersion: z.literal(1), + sources: z.array(registeredSourceSchema).max(PLUGIN_MARKETPLACE_SOURCE_LIMIT) +}) +const cachedSnapshotSchema = z.strictObject({ + schemaVersion: z.literal(1), + sourceId: sourceIdSchema, + source: pluginMarketplaceGitSourceSchema, + marketplaceCommit: z.string().regex(PLUGIN_COMMIT_PATTERN), + fetchedAt: z.number().finite().nonnegative(), + marketplace: pluginMarketplaceSchema +}) + +export type PluginMarketplaceRegisteredSource = z.infer +export type PluginMarketplaceCachedSnapshot = z.infer + +const SOURCE_FILE_MAX_BYTES = 2 * 1024 * 1024 +const SNAPSHOT_FILE_MAX_BYTES = 16 * 1024 * 1024 + +export function marketplaceSourceId(source: PluginMarketplaceGitSource): string { + const parsed = pluginMarketplaceGitSourceSchema.parse(source) + return createHash('sha256') + .update(`orca-plugin-marketplace-source-v1\0${parsed.url}\0${parsed.ref}`) + .digest('hex') + .slice(0, 32) +} + +export class PluginMarketplaceStore { + private readonly sourcesPath: string + private readonly snapshotDirectory: string + private sources: PluginMarketplaceRegisteredSource[] | null = null + private writeChain: Promise = Promise.resolve() + + constructor(pluginsDataDir: string) { + const root = join(pluginsDataDir, 'marketplaces') + this.sourcesPath = join(root, 'sources.json') + this.snapshotDirectory = join(root, 'snapshots') + } + + async listSources(): Promise { + await this.loadSources() + return this.sources! + } + + async addSource( + source: PluginMarketplaceGitSource, + addedAt = Date.now() + ): Promise { + const parsedSource = pluginMarketplaceGitSourceSchema.parse(source) + const registration = registeredSourceSchema.parse({ + id: marketplaceSourceId(parsedSource), + source: parsedSource, + addedAt + }) + await this.mutateSources((sources) => { + if (sources.some((candidate) => candidate.id === registration.id)) { + return [...sources] + } + if (sources.length >= PLUGIN_MARKETPLACE_SOURCE_LIMIT) { + throw new Error(`marketplace source limit (${PLUGIN_MARKETPLACE_SOURCE_LIMIT}) reached`) + } + return [...sources, registration] + }) + return this.sources!.find((candidate) => candidate.id === registration.id)! + } + + async removeSource(sourceId: string): Promise { + const parsedId = sourceIdSchema.parse(sourceId) + let removed = false + await this.mutateSources((sources) => { + const next = sources.filter((source) => source.id !== parsedId) + removed = next.length !== sources.length + return next + }) + if (removed) { + await rm(this.snapshotPath(parsedId), { force: true }) + } + return removed + } + + async writeSnapshot(input: { + source: PluginMarketplaceRegisteredSource + marketplaceCommit: string + fetchedAt?: number + marketplace: PluginMarketplace + }): Promise { + const snapshot = cachedSnapshotSchema.parse({ + schemaVersion: 1, + sourceId: input.source.id, + source: input.source.source, + marketplaceCommit: input.marketplaceCommit, + fetchedAt: input.fetchedAt ?? Date.now(), + marketplace: input.marketplace + }) + await writeAtomicJson(this.snapshotPath(input.source.id), snapshot) + return snapshot + } + + async readSnapshot(sourceId: string): Promise { + const parsedId = sourceIdSchema.parse(sourceId) + try { + const raw = JSON.parse( + await readBoundedText(this.snapshotPath(parsedId), SNAPSHOT_FILE_MAX_BYTES) + ) + const parsed = cachedSnapshotSchema.parse(raw) + if (parsed.sourceId !== parsedId) { + throw new Error('marketplace snapshot source identity does not match its cache path') + } + return parsed + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return null + } + throw new Error( + `marketplace snapshot is invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async loadSources(): Promise { + if (this.sources !== null) { + return + } + try { + const parsed = sourceFileSchema.parse( + JSON.parse(await readBoundedText(this.sourcesPath, SOURCE_FILE_MAX_BYTES)) + ) + const ids = new Set() + for (const source of parsed.sources) { + if (source.id !== marketplaceSourceId(source.source) || ids.has(source.id)) { + throw new Error('marketplace source identity is inconsistent or duplicated') + } + ids.add(source.id) + } + this.sources = parsed.sources + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + this.sources = [] + return + } + throw new Error( + `marketplace sources are invalid: ${error instanceof Error ? error.message : String(error)}` + ) + } + } + + private async mutateSources( + mutation: ( + sources: readonly PluginMarketplaceRegisteredSource[] + ) => PluginMarketplaceRegisteredSource[] + ): Promise { + const update = this.writeChain + .catch(() => undefined) + .then(async () => { + await this.loadSources() + const next = sourceFileSchema.parse({ + schemaVersion: 1, + sources: mutation(this.sources!) + }).sources + await writeAtomicJson(this.sourcesPath, { schemaVersion: 1, sources: next }) + this.sources = next + }) + this.writeChain = update + await update + } + + private snapshotPath(sourceId: string): string { + return join(this.snapshotDirectory, `${sourceId}.json`) + } +} + +async function readBoundedText(path: string, limit: number): Promise { + const chunks: Buffer[] = [] + let totalBytes = 0 + for await (const chunk of createReadStream(path)) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + totalBytes += bytes.byteLength + if (totalBytes > limit) { + throw new Error(`file exceeds its ${limit}-byte limit`) + } + chunks.push(bytes) + } + return Buffer.concat(chunks, totalBytes).toString('utf8') +} + +async function writeAtomicJson(path: string, value: unknown): Promise { + await mkdir(dirname(path), { recursive: true }) + await writePluginFileAtomically(path, `${JSON.stringify(value, null, 2)}\n`) +} diff --git a/src/main/plugins/plugin-panel-controller.test.ts b/src/main/plugins/plugin-panel-controller.test.ts new file mode 100644 index 00000000000..1a5af816b6c --- /dev/null +++ b/src/main/plugins/plugin-panel-controller.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { createPluginPanelCallAdmission } from '../../shared/plugins/plugin-panel-call-admission' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginPanelController } from './plugin-panel-controller' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function createPlugin(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-panel-controller-')) + roots.push(rootDir) + await writeFile(join(rootDir, 'panel.html'), '

    Panel

    ') + return { + pluginKey: 'orca-samples.demo', + rootDir, + manifest: pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + panels: [{ id: 'dashboard', title: 'Dashboard', entry: 'panel.html' }], + commands: [], + events: [] + }, + capabilities: [{ kind: 'notifications:show' }] + }), + consentFingerprint: 'sha256-consented', + contentHash: null, + isDev: true + } +} + +describe('PluginPanelController identity binding', () => { + it('uses the session identity and rejects caller-supplied plugin claims', async () => { + const plugin = await createPlugin() + const executeHostCall = vi.fn().mockResolvedValue({ ok: true, value: { delivered: true } }) + const controller = new PluginPanelController({ + resolveApprovedPlugin: (pluginKey) => (pluginKey === plugin.pluginKey ? plugin : null), + contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) }, + executeHostCall, + log: vi.fn() + }) + const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard') + expect(entry).not.toBeNull() + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + pluginId: 'orca-samples.other', + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + expect(executeHostCall).not.toHaveBeenCalled() + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: true }) + expect(executeHostCall).toHaveBeenCalledWith(plugin.pluginKey, 'notifications.show', { + title: 'Hello' + }) + await expect( + controller.execute('runtime:other', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + }) + + it('charges raw malformed and oversized calls before strict parsing', async () => { + const plugin = await createPlugin() + const executeHostCall = vi.fn() + const controller = new PluginPanelController({ + resolveApprovedPlugin: () => plugin, + contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) }, + executeHostCall, + log: vi.fn(), + panelAdmission: createPluginPanelCallAdmission({ + limits: { maxBytes: 128, maxMessages: 2, perMs: 10_000 }, + now: () => 0 + }) + }) + const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard') + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + unexpected: true + }) + ).resolves.toMatchObject({ ok: false, code: 'invalid_request' }) + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'x'.repeat(256) } + }) + ).resolves.toEqual({ + ok: false, + code: 'invalid_request', + error: 'panel message exceeds the size limit' + }) + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'third' } + }) + ).resolves.toEqual({ + ok: false, + code: 'rate_limited', + error: 'too many panel requests' + }) + expect(executeHostCall).not.toHaveBeenCalled() + }) + + it('does not publish stale panel code after approval changes during verification', async () => { + const plugin = await createPlugin() + let approved = true + let finishVerification!: () => void + const verification = new Promise((resolve) => { + finishVerification = resolve + }) + const controller = new PluginPanelController({ + resolveApprovedPlugin: () => (approved ? plugin : null), + contentVerifier: { verify: () => verification }, + executeHostCall: vi.fn(), + log: vi.fn() + }) + + const opening = controller.open('runtime:one', plugin.pluginKey, 'dashboard') + approved = false + finishVerification() + + await expect(opening).resolves.toBeNull() + }) + + it('invalidates an open dev-panel session when its manifest revision changes', async () => { + const plugin = await createPlugin() + let current = plugin + const executeHostCall = vi.fn().mockResolvedValue({ ok: true, value: { delivered: true } }) + const controller = new PluginPanelController({ + resolveApprovedPlugin: () => current, + contentVerifier: { verify: vi.fn().mockResolvedValue(undefined) }, + executeHostCall, + log: vi.fn() + }) + const entry = await controller.open('runtime:one', plugin.pluginKey, 'dashboard') + current = { + ...plugin, + manifest: pluginManifestSchema.parse({ ...plugin.manifest, version: '1.0.1' }) + } + + await expect( + controller.execute('runtime:one', { + sessionToken: entry!.sessionToken, + action: 'notifications.show', + params: { title: 'Hello' } + }) + ).resolves.toMatchObject({ ok: false, code: 'unavailable' }) + expect(executeHostCall).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/plugins/plugin-panel-controller.ts b/src/main/plugins/plugin-panel-controller.ts new file mode 100644 index 00000000000..32c45831c7d --- /dev/null +++ b/src/main/plugins/plugin-panel-controller.ts @@ -0,0 +1,170 @@ +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../../shared/plugins/plugin-panel-bridge' +import { panelActionCallSchema } from '../../shared/plugins/plugin-panel-bridge' +import { + admitPluginPanelCall, + createPluginPanelCallAdmission, + type PluginPanelCallAdmission +} from '../../shared/plugins/plugin-panel-call-admission' +import { buildPluginPanelShellHtml } from '../../shared/plugins/plugin-panel-shell' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { + PLUGIN_PANEL_ENTRY_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import { PluginPanelSessions, type PluginPanelSessionBinding } from './plugin-panel-sessions' + +type PluginPanelControllerOptions = { + resolveApprovedPlugin: (pluginKey: string) => ValidDiscoveredPlugin | null + contentVerifier: Pick + executeHostCall: ( + pluginKey: string, + method: string, + params: unknown + ) => Promise + log: (pluginKey: string, line: string) => void + panelAdmission?: PluginPanelCallAdmission +} + +type LoadedPluginPanel = { + entry: { html: string } + binding: PluginPanelSessionBinding +} + +export class PluginPanelController { + private readonly sessions = new PluginPanelSessions() + private readonly boundOwnerSignals = new WeakSet() + private readonly panelAdmission: PluginPanelCallAdmission + + constructor(private readonly options: PluginPanelControllerOptions) { + this.panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission() + } + + async readEntry(pluginKey: string, panelId: string): Promise<{ html: string } | null> { + return (await this.load(pluginKey, panelId))?.entry ?? null + } + + async open( + ownerKey: string, + pluginKey: string, + panelId: string + ): Promise { + const loaded = await this.load(pluginKey, panelId) + if (!loaded) { + return null + } + return { + ...loaded.entry, + sessionToken: this.sessions.issue(ownerKey, loaded.binding) + } + } + + async execute(ownerKey: string, call: unknown): Promise { + const sessionToken = this.extractSessionToken(call) + if (!sessionToken) { + return { ok: false, code: 'invalid_request', error: 'invalid panel session' } + } + const binding = this.sessions.resolve(ownerKey, sessionToken) + if (!binding) { + return { ok: false, code: 'invalid_request', error: 'invalid panel session' } + } + const admissionRefusal = admitPluginPanelCall(this.panelAdmission, binding.pluginKey, call) + if (admissionRefusal) { + return admissionRefusal + } + const parsed = panelActionCallSchema.safeParse(call) + if (!parsed.success) { + return { ok: false, code: 'invalid_request', error: 'malformed panel action call' } + } + const plugin = this.options.resolveApprovedPlugin(binding.pluginKey) + const panelExists = plugin?.manifest.contributes.panels.some( + (panel) => panel.id === binding.panelId + ) + if ( + !plugin || + plugin.rootDir !== binding.rootDir || + JSON.stringify(plugin.manifest) !== binding.manifestRevision || + !panelExists + ) { + return { ok: false, code: 'unavailable', error: 'panel session is no longer available' } + } + return this.options.executeHostCall(binding.pluginKey, parsed.data.action, parsed.data.params) + } + + revokeOwner(ownerKey: string): void { + this.sessions.revokeOwner(ownerKey) + } + + bindOwnerSignal(ownerKey: string, signal: AbortSignal | undefined): void { + if (!signal || this.boundOwnerSignals.has(signal)) { + return + } + this.boundOwnerSignals.add(signal) + if (signal.aborted) { + this.revokeOwner(ownerKey) + return + } + signal.addEventListener('abort', () => this.revokeOwner(ownerKey), { once: true }) + } + + revokeAll(): void { + this.sessions.clear() + this.panelAdmission.clear() + } + + dispose(): void { + this.revokeAll() + } + + private extractSessionToken(call: unknown): string | null { + if (typeof call !== 'object' || call === null) { + return null + } + try { + const token = (call as { sessionToken?: unknown }).sessionToken + return typeof token === 'string' && token.length >= 32 && token.length <= 128 ? token : null + } catch { + return null + } + } + + private async load(pluginKey: string, panelId: string): Promise { + const plugin = this.options.resolveApprovedPlugin(pluginKey) + const panel = plugin?.manifest.contributes.panels.find((entry) => entry.id === panelId) + if (!plugin || !panel) { + return null + } + try { + await this.options.contentVerifier.verify(plugin) + const html = buildPluginPanelShellHtml( + await readContainedPluginArtifactText( + plugin.rootDir, + panel.entry, + PLUGIN_PANEL_ENTRY_MAX_BYTES + ) + ) + const current = this.options.resolveApprovedPlugin(pluginKey) + if (current !== plugin || current.rootDir !== plugin.rootDir) { + return null + } + return { + entry: { html }, + binding: { + pluginKey, + panelId, + rootDir: plugin.rootDir, + manifestRevision: JSON.stringify(plugin.manifest) + } + } + } catch (error) { + this.options.log( + pluginKey, + `panel entry ${panel.entry} rejected: ${error instanceof Error ? error.message : String(error)}` + ) + return null + } + } +} diff --git a/src/main/plugins/plugin-panel-navigation-guard.test.ts b/src/main/plugins/plugin-panel-navigation-guard.test.ts new file mode 100644 index 00000000000..2889e57cf70 --- /dev/null +++ b/src/main/plugins/plugin-panel-navigation-guard.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' +import { PluginPanelNavigationRegistry } from './plugin-panel-navigation-guard' + +function frame(input: { id: number; name?: string; url?: string }) { + let destroyed = false + return { + frameTreeNodeId: input.id, + name: input.name ?? '', + isDestroyed: () => destroyed, + destroy: () => { + destroyed = true + } + } +} + +describe('PluginPanelNavigationRegistry', () => { + it('blocks only host-marked plugin srcdoc frames', () => { + const registry = new PluginPanelNavigationRegistry() + const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) + const notebook = frame({ id: 2 }) + registry.register(plugin) + registry.register(notebook) + + expect(registry.shouldBlock(plugin, null, 'about:srcdoc')).toBe(false) + expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(true) + expect(registry.shouldBlock(notebook, notebook, 'https://example.com')).toBe(false) + }) + + it('keeps pre-parse identity after name mutation and prunes destroyed frames', () => { + const registry = new PluginPanelNavigationRegistry() + const plugin = frame({ id: 1, name: `${PLUGIN_PANEL_FRAME_NAME_PREFIX}demo` }) + registry.register(plugin) + plugin.name = '' + expect(registry.shouldBlock(plugin, null, 'about:srcdoc')).toBe(false) + expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(true) + + plugin.destroy() + expect(registry.shouldBlock(plugin, plugin, 'https://example.com')).toBe(false) + }) +}) diff --git a/src/main/plugins/plugin-panel-navigation-guard.ts b/src/main/plugins/plugin-panel-navigation-guard.ts new file mode 100644 index 00000000000..d183eae9888 --- /dev/null +++ b/src/main/plugins/plugin-panel-navigation-guard.ts @@ -0,0 +1,75 @@ +import type { WebContents, WebFrameMain } from 'electron' +import { PLUGIN_PANEL_FRAME_NAME_PREFIX } from '../../shared/plugins/plugin-panel-bridge' + +type NavigationFrame = Pick + +type RegisteredFrame = { + frame: NavigationFrame + initialSrcdocPending: boolean +} + +/** Records host-marked panel frame identities at browsing-context creation, + * before plugin parsing can mutate window.name. */ +export class PluginPanelNavigationRegistry { + private readonly frames = new Map() + + register(frame: NavigationFrame): void { + this.prune() + if (frame.name.startsWith(PLUGIN_PANEL_FRAME_NAME_PREFIX)) { + this.frames.set(frame.frameTreeNodeId, { frame, initialSrcdocPending: true }) + } + } + + shouldBlock( + frame: NavigationFrame | null, + initiator: NavigationFrame | null, + destinationUrl: string + ): boolean { + this.prune() + const registeredTarget = frame ? this.frames.get(frame.frameTreeNodeId) : undefined + if (registeredTarget) { + // Why: registration happens before the host-provided srcdoc commits; + // allow exactly that initial document, then contain every navigation. + if (registeredTarget.initialSrcdocPending && destinationUrl === 'about:srcdoc') { + registeredTarget.initialSrcdocPending = false + return false + } + return true + } + return Boolean(initiator && this.frames.has(initiator.frameTreeNodeId)) + } + + clear(): void { + this.frames.clear() + } + + private prune(): void { + for (const [id, registered] of this.frames) { + if (registered.frame.isDestroyed()) { + this.frames.delete(id) + } + } + } +} + +export function registerPluginPanelNavigationGuard(webContents: WebContents): void { + const registry = new PluginPanelNavigationRegistry() + webContents.on('frame-created', (_event, { frame }) => { + if (frame) { + registry.register(frame) + } + }) + webContents.on('did-start-navigation', (event) => { + if (!event.isMainFrame && event.url === 'about:srcdoc' && event.frame) { + // Some Chromium builds populate the frame name only when navigation + // starts; this event still precedes document parsing and plugin code. + registry.register(event.frame) + } + }) + webContents.on('will-frame-navigate', (event) => { + if (registry.shouldBlock(event.frame, event.initiator ?? null, event.url)) { + event.preventDefault() + } + }) + webContents.on('destroyed', () => registry.clear()) +} diff --git a/src/main/plugins/plugin-panel-owner-lifecycle.test.ts b/src/main/plugins/plugin-panel-owner-lifecycle.test.ts new file mode 100644 index 00000000000..cef035f158d --- /dev/null +++ b/src/main/plugins/plugin-panel-owner-lifecycle.test.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { + bindPluginPanelOwnerLifecycle, + type PluginPanelOwnerSender +} from './plugin-panel-owner-lifecycle' + +describe('bindPluginPanelOwnerLifecycle', () => { + it('deduplicates hooks, revokes on renderer loss, and invalidates in-flight loads', () => { + const sender = new EventEmitter() as PluginPanelOwnerSender & EventEmitter + const revoke = vi.fn() + const first = bindPluginPanelOwnerLifecycle(sender, revoke) + const duplicate = bindPluginPanelOwnerLifecycle(sender, revoke) + + expect(sender.listenerCount('destroyed')).toBe(1) + expect(sender.listenerCount('render-process-gone')).toBe(1) + expect(first.isCurrent()).toBe(true) + expect(duplicate.isCurrent()).toBe(true) + + sender.emit('render-process-gone') + + expect(revoke).toHaveBeenCalledTimes(1) + expect(first.isCurrent()).toBe(false) + expect(duplicate.isCurrent()).toBe(false) + expect(sender.listenerCount('destroyed')).toBe(0) + + const restarted = bindPluginPanelOwnerLifecycle(sender, revoke) + expect(restarted.isCurrent()).toBe(true) + sender.emit('destroyed') + expect(revoke).toHaveBeenCalledTimes(2) + expect(restarted.isCurrent()).toBe(false) + }) +}) diff --git a/src/main/plugins/plugin-panel-owner-lifecycle.ts b/src/main/plugins/plugin-panel-owner-lifecycle.ts new file mode 100644 index 00000000000..5f400c583e6 --- /dev/null +++ b/src/main/plugins/plugin-panel-owner-lifecycle.ts @@ -0,0 +1,42 @@ +import type { WebContents } from 'electron' + +export type PluginPanelOwnerSender = Pick + +type OwnerState = { + bound: boolean + generation: number +} + +const ownerStates = new WeakMap() + +/** Deduplicates WebContents lifecycle hooks and returns a generation lease so + * an async panel load cannot publish a session after its renderer died. */ +export function bindPluginPanelOwnerLifecycle( + sender: PluginPanelOwnerSender, + revoke: () => void +): { isCurrent: () => boolean } { + let state = ownerStates.get(sender) + if (!state) { + state = { bound: false, generation: 0 } + ownerStates.set(sender, state) + } + if (!state.bound) { + state.bound = true + let finished = false + const cleanup = (): void => { + if (finished) { + return + } + finished = true + sender.removeListener('destroyed', cleanup) + sender.removeListener('render-process-gone', cleanup) + state!.bound = false + state!.generation += 1 + revoke() + } + sender.once('destroyed', cleanup) + sender.once('render-process-gone', cleanup) + } + const generation = state.generation + return { isCurrent: () => state!.bound && state!.generation === generation } +} diff --git a/src/main/plugins/plugin-panel-sessions.test.ts b/src/main/plugins/plugin-panel-sessions.test.ts new file mode 100644 index 00000000000..df38e34c1da --- /dev/null +++ b/src/main/plugins/plugin-panel-sessions.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { PluginPanelSessions } from './plugin-panel-sessions' + +const binding = { + pluginKey: 'orca-samples.demo', + panelId: 'dashboard', + rootDir: '/plugins/orca-samples.demo/hash-one', + manifestRevision: 'manifest-v1' +} + +describe('PluginPanelSessions', () => { + it('binds an opaque token to its transport owner and panel revision', () => { + const sessions = new PluginPanelSessions() + const token = sessions.issue('renderer:1', binding) + + expect(token).toHaveLength(43) + expect(sessions.resolve('renderer:1', token)).toEqual(binding) + expect(sessions.resolve('renderer:2', token)).toBeNull() + expect(sessions.issue('renderer:1', binding)).toBe(token) + expect(sessions.issue('renderer:1', { ...binding, rootDir: '/plugins/new' })).not.toBe(token) + expect(sessions.issue('renderer:1', { ...binding, manifestRevision: 'manifest-v2' })).not.toBe( + token + ) + }) + + it('revokes every session owned by a disconnected transport', () => { + const sessions = new PluginPanelSessions() + const first = sessions.issue('connection:one', binding) + const second = sessions.issue('connection:one', { ...binding, panelId: 'secondary' }) + const other = sessions.issue('connection:two', binding) + + sessions.revokeOwner('connection:one') + + expect(sessions.resolve('connection:one', first)).toBeNull() + expect(sessions.resolve('connection:one', second)).toBeNull() + expect(sessions.resolve('connection:two', other)).toEqual(binding) + }) +}) diff --git a/src/main/plugins/plugin-panel-sessions.ts b/src/main/plugins/plugin-panel-sessions.ts new file mode 100644 index 00000000000..0f1fcf4f533 --- /dev/null +++ b/src/main/plugins/plugin-panel-sessions.ts @@ -0,0 +1,84 @@ +import { randomBytes } from 'node:crypto' + +export type PluginPanelSessionBinding = { + pluginKey: string + panelId: string + rootDir: string + manifestRevision: string +} + +type PluginPanelSession = PluginPanelSessionBinding & { + ownerKey: string +} + +const MAX_PANEL_SESSIONS = 1_024 + +function bindingKey(ownerKey: string, binding: PluginPanelSessionBinding): string { + return JSON.stringify([ + ownerKey, + binding.pluginKey, + binding.panelId, + binding.rootDir, + binding.manifestRevision + ]) +} + +/** Opaque bearer sessions bind a loaded panel to its transport owner without + * accepting a plugin identity on later action calls. */ +export class PluginPanelSessions { + private readonly sessions = new Map() + private readonly tokensByBinding = new Map() + + issue(ownerKey: string, binding: PluginPanelSessionBinding): string { + const key = bindingKey(ownerKey, binding) + const existing = this.tokensByBinding.get(key) + if (existing) { + return existing + } + while (this.sessions.size >= MAX_PANEL_SESSIONS) { + const oldest = this.sessions.entries().next().value as + | [string, PluginPanelSession] + | undefined + if (!oldest) { + break + } + this.delete(oldest[0], oldest[1]) + } + const token = randomBytes(32).toString('base64url') + const session = { ownerKey, ...binding } + this.sessions.set(token, session) + this.tokensByBinding.set(key, token) + return token + } + + resolve(ownerKey: string, token: string): PluginPanelSessionBinding | null { + const session = this.sessions.get(token) + if (!session || session.ownerKey !== ownerKey) { + return null + } + return { + pluginKey: session.pluginKey, + panelId: session.panelId, + rootDir: session.rootDir, + manifestRevision: session.manifestRevision + } + } + + revokeOwner(ownerKey: string): void { + for (const [token, session] of this.sessions) { + if (session.ownerKey === ownerKey) { + this.delete(token, session) + } + } + } + + clear(): void { + this.sessions.clear() + this.tokensByBinding.clear() + } + + private delete(token: string, session: PluginPanelSession): void { + this.sessions.delete(token) + this.tokensByBinding.delete(bindingKey(session.ownerKey, session)) + } +} diff --git a/src/main/plugins/plugin-private-marketplace-ssh-shim.cjs b/src/main/plugins/plugin-private-marketplace-ssh-shim.cjs new file mode 100644 index 00000000000..c88809a37ad --- /dev/null +++ b/src/main/plugins/plugin-private-marketplace-ssh-shim.cjs @@ -0,0 +1,12 @@ +const { spawnSync } = require('node:child_process') + +const command = process.argv.at(-1) ?? '' +const match = /^git-upload-pack '([^']+)'$/.exec(command) +const repositories = JSON.parse(process.env.ORCA_TEST_SSH_REPOSITORIES ?? '{}') +const repository = match ? repositories[match[1]] : undefined +if (!repository) { + process.stderr.write(`unknown test SSH repository: ${command}\n`) + process.exit(1) +} +const result = spawnSync('git', ['upload-pack', repository], { stdio: 'inherit' }) +process.exit(result.status ?? 1) diff --git a/src/main/plugins/plugin-private-marketplace.integration.test.ts b/src/main/plugins/plugin-private-marketplace.integration.test.ts new file mode 100644 index 00000000000..7c37025487c --- /dev/null +++ b/src/main/plugins/plugin-private-marketplace.integration.test.ts @@ -0,0 +1,153 @@ +import { execFile } from 'node:child_process' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { afterEach, describe, expect, it } from 'vitest' +import type { PluginMarketplaceGitSource } from '../../shared/plugins/plugin-marketplace' +import { getUserPluginsDir } from './plugin-discovery' +import { readPluginLockfile } from './plugin-install' +import { PluginMarketplaceInstaller } from './plugin-marketplace-installer' +import { PluginMarketplaceService } from './plugin-marketplace-service' + +const execFileAsync = promisify(execFile) +const temporaryRoots: string[] = [] +const savedEnvironment = { + GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND, + GIT_SSH_VARIANT: process.env.GIT_SSH_VARIANT, + ORCA_TEST_SSH_REPOSITORIES: process.env.ORCA_TEST_SSH_REPOSITORIES +} + +async function runGit(cwd: string, args: string[]): Promise { + await execFileAsync('git', args, { cwd }) +} + +async function createGitRepository( + root: string, + name: string, + files: Record +): Promise { + const repository = join(root, name) + await mkdir(repository, { recursive: true }) + for (const [relativePath, contents] of Object.entries(files)) { + const path = join(repository, relativePath) + await mkdir(join(path, '..'), { recursive: true }) + await writeFile(path, contents, 'utf8') + } + await runGit(repository, ['init', '--quiet']) + await runGit(repository, ['checkout', '--quiet', '-b', 'main']) + await runGit(repository, ['add', '--all']) + await runGit(repository, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca-test@example.invalid', + 'commit', + '--quiet', + '-m', + 'fixture' + ]) + return repository +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'` +} + +afterEach(async () => { + for (const [key, value] of Object.entries(savedEnvironment)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })) + ) +}) + +describe('private Git marketplace integration', () => { + it('uses the caller SSH environment for marketplace preview and install', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-private-marketplace-')) + temporaryRoots.push(root) + const pluginKey = 'private.private-locale' + const pluginUrl = 'ssh://git@example.invalid/private/locale.git' + const marketplaceUrl = 'ssh://git@example.invalid/private/marketplace.git' + const pluginRepository = await createGitRepository(root, 'locale-source', { + 'orca-plugin.json': JSON.stringify({ + manifestVersion: 1, + id: 'private-locale', + publisher: 'private', + name: 'Private Locale', + version: '1.0.0', + engines: { orca: '>=1.4.0' }, + pluginApi: 1, + contributes: { + languagePacks: [{ locale: 'pt-BR', path: 'locale.json' }] + }, + capabilities: [] + }), + 'locale.json': JSON.stringify({ + settings: { title: 'Ajustes' } + }) + }) + const marketplaceRepository = await createGitRepository(root, 'marketplace-source', { + 'orca-marketplace.json': JSON.stringify({ + name: 'Private Team Plugins', + owner: 'private-team', + plugins: [ + { + id: pluginKey, + source: { kind: 'git', url: pluginUrl, ref: 'main' }, + categories: ['languages'] + } + ] + }) + }) + const sshShim = join(root, 'git-ssh-shim.cjs') + await writeFile( + sshShim, + await readFile(join(import.meta.dirname, 'plugin-private-marketplace-ssh-shim.cjs'), 'utf8'), + 'utf8' + ) + process.env.GIT_SSH_COMMAND = `${shellQuote(process.execPath.replaceAll('\\', '/'))} ${shellQuote(sshShim.replaceAll('\\', '/'))}` + process.env.GIT_SSH_VARIANT = 'ssh' + process.env.ORCA_TEST_SSH_REPOSITORIES = JSON.stringify({ + '/private/locale.git': pluginRepository, + '/private/marketplace.git': marketplaceRepository + }) + + const userDataPath = join(root, 'user-data') + const marketplace = new PluginMarketplaceService({ + pluginsDataDir: join(userDataPath, 'plugins-data') + }) + const source: PluginMarketplaceGitSource = { + kind: 'git', + url: marketplaceUrl, + ref: 'main' + } + const registered = await marketplace.addSource(source) + const installer = new PluginMarketplaceInstaller({ + marketplace, + userDataPath, + hostVersion: '1.4.0' + }) + + const preview = await installer.preview(registered.id, pluginKey) + const installed = await installer.install(preview) + + expect(registered).toMatchObject({ + stale: false, + marketplace: { name: 'Private Team Plugins' } + }) + expect(preview).toMatchObject({ pluginKey, official: false, source: { url: pluginUrl } }) + expect(installed).toMatchObject({ ok: true, pluginKey }) + const lock = await readPluginLockfile(getUserPluginsDir(userDataPath)) + expect(lock.plugins[pluginKey]?.source).toMatchObject({ + kind: 'marketplace', + marketplace: { url: marketplaceUrl }, + plugin: { url: pluginUrl } + }) + }) +}) diff --git a/src/main/plugins/plugin-refresh-settlement.ts b/src/main/plugins/plugin-refresh-settlement.ts new file mode 100644 index 00000000000..47bc464d20f --- /dev/null +++ b/src/main/plugins/plugin-refresh-settlement.ts @@ -0,0 +1,11 @@ +export async function waitForPluginRefreshSettlement( + getCurrent: () => Promise +): Promise { + while (true) { + const pending = getCurrent() + await pending.catch(() => undefined) + if (pending === getCurrent()) { + return + } + } +} diff --git a/src/main/plugins/plugin-secrets-store.test.ts b/src/main/plugins/plugin-secrets-store.test.ts new file mode 100644 index 00000000000..b14bf9dd548 --- /dev/null +++ b/src/main/plugins/plugin-secrets-store.test.ts @@ -0,0 +1,117 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const storageMocks = vi.hoisted(() => ({ + available: true, + encryptString: vi.fn((value: string) => Buffer.from(`encrypted:${value}`, 'utf8')), + decryptString: vi.fn((value: Buffer) => { + const text = value.toString('utf8') + if (!text.startsWith('encrypted:')) { + throw new Error('wrong key or corrupt ciphertext') + } + return text.slice('encrypted:'.length) + }) +})) + +vi.mock('electron', () => ({ + safeStorage: { + isEncryptionAvailable: () => storageMocks.available, + encryptString: storageMocks.encryptString, + decryptString: storageMocks.decryptString + } +})) + +import { PluginSecretsStore } from './plugin-secrets-store' + +const roots: string[] = [] + +async function tempRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-secrets-')) + roots.push(root) + return root +} + +beforeEach(() => { + storageMocks.available = true + storageMocks.encryptString.mockImplementation((value) => + Buffer.from(`encrypted:${value}`, 'utf8') + ) + storageMocks.decryptString.mockImplementation((value) => { + const text = value.toString('utf8') + if (!text.startsWith('encrypted:')) { + throw new Error('wrong key or corrupt ciphertext') + } + return text.slice('encrypted:'.length) + }) +}) + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginSecretsStore', () => { + it('encrypts, persists, decrypts, deletes, and isolates plugin namespaces', async () => { + const root = await tempRoot() + const first = new PluginSecretsStore(root, 'acme.first') + const second = new PluginSecretsStore(root, 'acme.second') + + expect(first.set('token', 'top-secret')).toEqual({ ok: true, value: true }) + expect(first.get('token')).toEqual({ ok: true, value: 'top-secret' }) + expect(second.get('token')).toEqual({ ok: true, value: null }) + const persisted = await readFile(join(root, 'acme.first', 'secrets.json.enc'), 'utf8') + expect(persisted).not.toContain('top-secret') + if (process.platform !== 'win32') { + expect((await stat(join(root, 'acme.first', 'secrets.json.enc'))).mode & 0o077).toBe(0) + } + + first.delete('token') + expect(first.get('token')).toEqual({ ok: true, value: null }) + }) + + it('fails closed without OS encryption and writes no plaintext file', async () => { + const root = await tempRoot() + storageMocks.available = false + const store = new PluginSecretsStore(root, 'acme.demo') + + expect(store.set('token', 'plaintext')).toMatchObject({ ok: false }) + expect(store.get('token')).toMatchObject({ ok: true, value: null }) + await expect(readFile(join(root, 'acme.demo', 'secrets.json.enc'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('reports corrupt or wrong-key ciphertext without returning bytes', async () => { + const root = await tempRoot() + const pluginDir = join(root, 'acme.demo') + await mkdir(pluginDir, { recursive: true }) + await writeFile( + join(pluginDir, 'secrets.json.enc'), + JSON.stringify({ + version: 1, + format: 'electron-safe-storage-v1', + ciphertexts: { token: Buffer.from('not-encrypted').toString('base64') } + }) + ) + const store = new PluginSecretsStore(root, 'acme.demo') + + expect(store.get('token')).toEqual({ ok: false, error: 'failed to decrypt stored secret' }) + }) + + it('refuses ciphertext that would exceed the bounded vault', async () => { + const root = await tempRoot() + storageMocks.encryptString.mockReturnValue(Buffer.alloc(6 * 1024 * 1024)) + const store = new PluginSecretsStore(root, 'acme.demo') + + expect(store.set('token', 'small-input')).toMatchObject({ ok: false }) + await expect(readFile(join(root, 'acme.demo', 'secrets.json.enc'))).rejects.toMatchObject({ + code: 'ENOENT' + }) + }) + + it('rejects unsafe plugin namespaces', async () => { + const root = await tempRoot() + expect(() => new PluginSecretsStore(root, 'constructor.demo')).toThrow('unsafe plugin key') + }) +}) diff --git a/src/main/plugins/plugin-secrets-store.ts b/src/main/plugins/plugin-secrets-store.ts new file mode 100644 index 00000000000..7f16821cfb6 --- /dev/null +++ b/src/main/plugins/plugin-secrets-store.ts @@ -0,0 +1,108 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { safeStorage } from 'electron' +import { writeSecureFile } from '../../shared/secure-file' +import { + PLUGIN_STORAGE_KEY_LIMIT, + PLUGIN_STORAGE_TOTAL_MAX_BYTES +} from '../../shared/plugins/plugin-host-api' +import { pluginDataDir } from './plugin-storage-store' + +/** + * Per-plugin secret vault, following the repo's safeStorage-backed + * credential-file pattern (versioned envelope + base64 ciphertext via the + * atomic secure-file writer). No plaintext fallback: when OS encryption is + * unavailable, writes fail loudly instead of silently downgrading — plugin + * secrets are API-token grade. + */ + +type PersistedSecretsFile = { + version: 1 + format: 'electron-safe-storage-v1' + /** key → base64 ciphertext of the secret value. */ + ciphertexts: Record +} + +export type PluginSecretsResult = { ok: true; value: T } | { ok: false; error: string } + +export class PluginSecretsStore { + private readonly filePath: string + + constructor(pluginsDataDir: string, qualifiedKey: string) { + this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), 'secrets.json.enc') + } + + private read(): PersistedSecretsFile { + const empty: PersistedSecretsFile = { + version: 1, + format: 'electron-safe-storage-v1', + ciphertexts: {} + } + try { + if (!existsSync(this.filePath)) { + return empty + } + if (statSync(this.filePath).size > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return empty + } + const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as PersistedSecretsFile + if ( + parsed && + parsed.version === 1 && + parsed.format === 'electron-safe-storage-v1' && + parsed.ciphertexts && + typeof parsed.ciphertexts === 'object' && + !Array.isArray(parsed.ciphertexts) + ) { + return parsed + } + } catch { + // Corrupt vaults read as empty; set() rewrites a valid file. + } + return empty + } + + get(key: string): PluginSecretsResult { + const file = this.read() + const ciphertext = file.ciphertexts[key] + if (typeof ciphertext !== 'string') { + return { ok: true, value: null } + } + if (!safeStorage.isEncryptionAvailable()) { + return { ok: false, error: 'OS-backed encryption is unavailable' } + } + try { + return { ok: true, value: safeStorage.decryptString(Buffer.from(ciphertext, 'base64')) } + } catch { + return { ok: false, error: 'failed to decrypt stored secret' } + } + } + + set(key: string, value: string): PluginSecretsResult { + if (!safeStorage.isEncryptionAvailable()) { + return { ok: false, error: 'OS-backed encryption is unavailable; secret not stored' } + } + const file = this.read() + if ( + !Object.hasOwn(file.ciphertexts, key) && + Object.keys(file.ciphertexts).length >= PLUGIN_STORAGE_KEY_LIMIT + ) { + return { ok: false, error: `secret vault exceeds the ${PLUGIN_STORAGE_KEY_LIMIT}-key limit` } + } + file.ciphertexts[key] = safeStorage.encryptString(value).toString('base64') + const nextFile = JSON.stringify(file, null, 2) + if (Buffer.byteLength(nextFile, 'utf8') > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return { ok: false, error: `secret vault exceeds ${PLUGIN_STORAGE_TOTAL_MAX_BYTES} bytes` } + } + writeSecureFile(this.filePath, nextFile) + return { ok: true, value: true } + } + + delete(key: string): void { + const file = this.read() + if (Object.hasOwn(file.ciphertexts, key)) { + delete file.ciphertexts[key] + writeSecureFile(this.filePath, JSON.stringify(file, null, 2)) + } + } +} diff --git a/src/main/plugins/plugin-service-housekeeping.ts b/src/main/plugins/plugin-service-housekeeping.ts new file mode 100644 index 00000000000..fb1032bac4c --- /dev/null +++ b/src/main/plugins/plugin-service-housekeeping.ts @@ -0,0 +1,47 @@ +import { PluginDevWatcher } from './plugin-dev-watcher' + +/** Starts and stops lifecycle maintenance as the feature flag and dev paths change. */ +export class PluginServiceHousekeeping { + private readonly devWatcher = new PluginDevWatcher() + private reapTimer: ReturnType | null = null + private watchedPathsKey: string | null = null + + sync(options: { + enabled: boolean + devPaths: readonly string[] + reapIdle: () => void + refresh: () => void + }): void { + if (!options.enabled) { + this.stop() + return + } + if (!this.reapTimer) { + this.reapTimer = setInterval(options.reapIdle, 60_000) + this.reapTimer.unref?.() + } + const pathsKey = JSON.stringify(options.devPaths) + if (pathsKey !== this.watchedPathsKey) { + this.devWatcher.dispose() + this.devWatcher.start(options.devPaths, options.refresh, () => { + // The next refresh retries a failed watcher even when the configured + // path list itself did not change. + this.watchedPathsKey = null + }) + this.watchedPathsKey = pathsKey + } + } + + dispose(): void { + this.stop() + } + + private stop(): void { + if (this.reapTimer) { + clearInterval(this.reapTimer) + this.reapTimer = null + } + this.devWatcher.dispose() + this.watchedPathsKey = null + } +} diff --git a/src/main/plugins/plugin-service-integrity.test.ts b/src/main/plugins/plugin-service-integrity.test.ts new file mode 100644 index 00000000000..56a344eb3f8 --- /dev/null +++ b/src/main/plugins/plugin-service-integrity.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import { hashPluginTree } from './plugin-content-hash' +import { PluginContentVerifier } from './plugin-content-integrity' +import { PluginService } from './plugin-service' +import type { PluginWorkerFactory } from './plugin-worker-manager' + +const roots: string[] = [] + +async function createInstalledPlugin(options: { worker: boolean }): Promise<{ + userDataPath: string + pluginKey: string + rootDir: string + manifest: PluginManifest +}> { + const userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-service-integrity-')) + roots.push(userDataPath) + const pluginKey = 'orca-samples.demo' + const pluginDir = join(userDataPath, 'plugins', pluginKey) + const stagingDir = join(pluginDir, 'staging') + await mkdir(stagingDir, { recursive: true }) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + ...(options.worker ? { main: 'worker.js' } : {}), + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: options.worker ? [{ id: 'run', title: 'Run' }] : [], + events: [] + }, + capabilities: [] + }) + await writeFile(join(stagingDir, 'orca-plugin.json'), JSON.stringify(manifest)) + await writeFile(join(stagingDir, 'panel.html'), '

    Panel

    ') + await writeFile(join(stagingDir, 'payload.txt'), 'original') + if (options.worker) { + await writeFile(join(stagingDir, 'worker.js'), 'export default async function () {}') + } + const content = await hashPluginTree(stagingDir) + if (!content.ok) { + throw new Error(content.error) + } + const rootDir = join(pluginDir, content.hash) + await rename(stagingDir, rootDir) + await writeFile(join(pluginDir, 'current'), content.hash) + return { userDataPath, pluginKey, rootDir, manifest } +} + +function createService( + plugin: Awaited>, + workerFactory?: PluginWorkerFactory +): PluginService { + const consentFingerprint = fingerprintPluginConsent(plugin.manifest) + return new PluginService({ + userDataPath: plugin.userDataPath, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ [plugin.pluginKey]: consentFingerprint }), + getDevPluginPaths: () => [], + workerFactory + }) +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginService lazy content verification', () => { + it('does not share an in-flight verification across same-key content revisions', async () => { + const oldPlugin = await createInstalledPlugin({ worker: false }) + const newPlugin = await createInstalledPlugin({ worker: false }) + await writeFile(join(oldPlugin.rootDir, 'payload.txt'), 'tampered old revision') + const verifier = new PluginContentVerifier() + + const oldVerification = verifier.verify({ + pluginKey: oldPlugin.pluginKey, + rootDir: oldPlugin.rootDir, + contentHash: basename(oldPlugin.rootDir) + }) + const newVerification = verifier.verify({ + pluginKey: newPlugin.pluginKey, + rootDir: newPlugin.rootDir, + contentHash: basename(newPlugin.rootDir) + }) + + await expect(oldVerification).rejects.toThrow('integrity verification') + await expect(newVerification).resolves.toBeUndefined() + }) + + it('detects tampering only when panel code is first consumed', async () => { + const plugin = await createInstalledPlugin({ worker: false }) + const service = createService(plugin) + await service.initialize() + expect(service.findValidPlugin(plugin.pluginKey)).not.toBeNull() + + await writeFile(join(plugin.rootDir, 'payload.txt'), 'tampered after discovery') + + await expect(service.panels.readEntry(plugin.pluginKey, 'panel')).resolves.toBeNull() + await service.dispose() + }) + + it('blocks a worker fork when installed content changed after discovery', async () => { + const plugin = await createInstalledPlugin({ worker: true }) + const workerFactory = vi.fn() + const service = createService(plugin, workerFactory) + await service.initialize() + await writeFile(join(plugin.rootDir, 'payload.txt'), 'tampered after discovery') + + await expect(service.invokeCommand(plugin.pluginKey, 'run')).rejects.toThrow( + 'integrity verification' + ) + expect(workerFactory).not.toHaveBeenCalled() + await service.dispose() + }) +}) diff --git a/src/main/plugins/plugin-service-options.ts b/src/main/plugins/plugin-service-options.ts new file mode 100644 index 00000000000..f6df4ba8edf --- /dev/null +++ b/src/main/plugins/plugin-service-options.ts @@ -0,0 +1,18 @@ +import type { PluginWorkerFactory } from './plugin-worker-manager' +import type { KeybindingOverrides } from '../../shared/keybindings' +import type { PluginKillListEntry } from '../../shared/plugins/plugin-kill-list' + +export type PluginServiceOptions = { + userDataPath: string + hostVersion: string + isPluginSystemEnabled: () => boolean + getDisabledPlugins: () => string[] + getPluginConsents: () => Record + getDevPluginPaths: () => string[] + getKeybindings?: () => KeybindingOverrides + getPluginKillListEntry?: (pluginKey: string) => PluginKillListEntry | null + hostEntryPath?: string + workerFactory?: PluginWorkerFactory + maxActiveWorkers?: number + idleReapMs?: number +} diff --git a/src/main/plugins/plugin-service-reconciliation.test.ts b/src/main/plugins/plugin-service-reconciliation.test.ts new file mode 100644 index 00000000000..eb373d654da --- /dev/null +++ b/src/main/plugins/plugin-service-reconciliation.test.ts @@ -0,0 +1,462 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { KeybindingOverrides } from '../../shared/keybindings' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest' +import type { PluginWorkerHandle } from './plugin-host-process' +import { PluginService } from './plugin-service' +import type { PluginWorkerFactory } from './plugin-worker-manager' +import { hashPluginTree } from './plugin-content-hash' + +const roots: string[] = [] +const services: PluginService[] = [] +const pluginKey = 'orca-samples.demo' + +function manifest(options: { main?: string; capabilities?: PluginManifest['capabilities'] } = {}) { + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + main: options.main ?? 'worker.js', + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [{ id: 'run', title: 'Run' }], + events: [] + }, + capabilities: options.capabilities ?? [] + }) +} + +async function pluginRoot(pluginManifest = manifest()): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-reconcile-')) + roots.push(root) + await writeFile(join(root, 'orca-plugin.json'), JSON.stringify(pluginManifest)) + await writeFile(join(root, 'worker.js'), 'export default async function () {}') + await writeFile(join(root, 'worker-v2.js'), 'export default async function () {}') + await writeFile(join(root, 'panel.html'), '

    Panel

    ') + return root +} + +function testWorker(): PluginWorkerHandle & { dispose: ReturnType } { + return { + commands: ['run'], + invokeCommand: vi.fn(async () => null), + deliverEvent: vi.fn(), + lastActivityAt: () => Date.now(), + inFlightCount: () => 0, + dispose: vi.fn(async () => undefined), + kill: vi.fn(), + onExit: vi.fn() + } +} + +function createHarness(root: string) { + let enabled = true + let disabled: string[] = [] + let devPaths = [root] + let killed = false + const consent = fingerprintPluginConsent(manifest()) + const workers: ReturnType[] = [] + const factory = vi.fn(async () => { + const handle = testWorker() + workers.push(handle) + return handle + }) + const service = new PluginService({ + userDataPath: root, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => enabled, + getDisabledPlugins: () => disabled, + getPluginConsents: () => ({ [pluginKey]: consent }), + getDevPluginPaths: () => devPaths, + getPluginKillListEntry: (key) => + killed && key === pluginKey + ? { pluginKey, reason: 'Security incident', advisoryUrl: 'https://orca.example/advisory' } + : null, + workerFactory: factory + }) + services.push(service) + return { + service, + factory, + workers, + setEnabled: (value: boolean) => { + enabled = value + }, + setDisabled: (value: string[]) => { + disabled = value + }, + setDevPaths: (value: string[]) => { + devPaths = value + }, + setKilled: (value: boolean) => { + killed = value + } + } +} + +async function activate(service: PluginService): Promise { + await service.initialize() + await service.invokeCommand(pluginKey, 'run') +} + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(services.splice(0).map((service) => service.dispose())) + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginService worker reconciliation', () => { + it('blocks every runtime surface until a saved override resolves a content conflict', async () => { + const conflictingManifest = (id: string): PluginManifest => + pluginManifestSchema.parse({ + manifestVersion: 1, + id, + publisher: 'orca-samples', + name: id, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + main: 'worker.js', + contributes: { + panels: [{ id: 'panel', title: 'Panel', entry: 'panel.html' }], + commands: [{ id: 'run', title: 'Run' }], + keybindings: [{ command: 'run', key: 'Mod+Alt+T' }], + events: [{ on: 'worktree.created' }] + }, + capabilities: [{ kind: 'events:subscribe' }] + }) + const firstManifest = conflictingManifest('first') + const secondManifest = conflictingManifest('second') + const firstRoot = await pluginRoot(firstManifest) + const secondRoot = await pluginRoot(secondManifest) + const firstHash = await hashPluginTree(firstRoot) + const secondHash = await hashPluginTree(secondRoot) + if (!firstHash.ok || !secondHash.ok) { + throw new Error('could not hash conflict fixtures') + } + let keybindings: KeybindingOverrides = {} + const factory = vi.fn(async () => testWorker()) + const service = new PluginService({ + userDataPath: firstRoot, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ + 'orca-samples.first': fingerprintPluginConsent(firstManifest, firstHash.hash), + 'orca-samples.second': fingerprintPluginConsent(secondManifest, secondHash.hash) + }), + getDevPluginPaths: () => [firstRoot, secondRoot], + getKeybindings: () => keybindings, + workerFactory: factory + }) + services.push(service) + + await service.initialize() + + expect(service.activationError('orca-samples.first')).toContain('conflicts') + expect(service.getGrantedCapabilities('orca-samples.first')).toBeNull() + await expect(service.invokeCommand('orca-samples.first', 'run')).rejects.toThrow('not enabled') + await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toBeNull() + service.emitEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(factory).not.toHaveBeenCalled() + + keybindings = { 'plugin:orca-samples.first/run': ['Mod+Shift+T'] } + await service.reconcileActivationState() + + expect(service.activationError('orca-samples.first')).toBeNull() + await expect(service.panels.readEntry('orca-samples.first', 'panel')).resolves.toMatchObject({ + html: expect.stringContaining('

    Panel

    ') + }) + await expect(service.invokeCommand('orca-samples.first', 'run')).resolves.toBeNull() + expect(factory).toHaveBeenCalledOnce() + }) + + it('rejects declarative aliases at the worker-command boundary without activating code', async () => { + const aliasManifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] + }, + capabilities: [] + }) + const root = await pluginRoot(aliasManifest) + const factory = vi.fn() + const service = new PluginService({ + userDataPath: root, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ [pluginKey]: fingerprintPluginConsent(aliasManifest) }), + getDevPluginPaths: () => [root], + workerFactory: factory + }) + services.push(service) + + await service.initialize() + + await expect(service.invokeCommand(pluginKey, 'tasks')).rejects.toThrow( + 'is a built-in action alias' + ) + expect(factory).not.toHaveBeenCalled() + }) + + it('denies every authority boundary immediately when the feature flag turns off', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + const plugin = harness.service.findValidPlugin(pluginKey)! + const opened = await harness.service.panels.open('renderer:1', pluginKey, 'panel') + expect(opened).not.toBeNull() + + harness.setEnabled(false) + + expect(harness.service.activationState(plugin)).toBe('disabled') + expect(harness.service.getGrantedCapabilities(pluginKey)).toBeNull() + await expect(harness.service.invokeCommand(pluginKey, 'run')).rejects.toThrow('not enabled') + await expect(harness.service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull() + + await harness.service.refresh() + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + harness.setEnabled(true) + await harness.service.refresh() + await expect( + harness.service.panels.execute('renderer:1', { + sessionToken: opened!.sessionToken, + action: 'notifications.show', + params: { title: 'stale' } + }) + ).resolves.toMatchObject({ ok: false, error: 'invalid panel session' }) + }) + + it('deactivates a worker when its plugin becomes disabled', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + + harness.setDisabled([pluginKey]) + await harness.service.refresh() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.workerState(pluginKey).state).toBe('inactive') + }) + + it('immediately revokes every authority surface and stops a killed plugin', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + expect(await harness.service.panels.open('renderer:1', pluginKey, 'panel')).not.toBeNull() + + harness.setKilled(true) + + expect(harness.service.getGrantedCapabilities(pluginKey)).toBeNull() + expect(harness.service.activationError(pluginKey)).toContain('Security incident') + await expect(harness.service.invokeCommand(pluginKey, 'run')).rejects.toThrow('not enabled') + await expect(harness.service.panels.readEntry(pluginKey, 'panel')).resolves.toBeNull() + harness.service.emitEvent('worktree.created', { + worktreeId: 'worktree-1', + path: '/repo', + branch: 'feature' + }) + await harness.service.reconcileActivationState() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.options.getPluginKillListEntry?.(pluginKey)).toMatchObject({ + reason: 'Security incident' + }) + }) + + it('deactivates a worker when changed capabilities make consent pending', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify(manifest({ capabilities: [{ kind: 'storage' }] })) + ) + + await harness.service.refresh() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.activationState(harness.service.findValidPlugin(pluginKey)!)).toBe( + 'pending' + ) + }) + + it('cancels the old generation when a worker spec changes without eager reactivation', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await activate(harness.service) + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify(manifest({ main: 'worker-v2.js' })) + ) + + await harness.service.refresh() + + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.factory).toHaveBeenCalledTimes(1) + await harness.service.invokeCommand(pluginKey, 'run') + expect(harness.factory).toHaveBeenCalledTimes(2) + expect(harness.factory.mock.calls[1]?.[0].mainEntry).toBe('worker-v2.js') + }) + + it('cannot reactivate the old revision while refresh awaits worker shutdown', async () => { + const root = await pluginRoot() + let finishOldDispose!: () => void + const oldDispose = new Promise((resolve) => { + finishOldDispose = resolve + }) + const workers: ReturnType[] = [] + const factory = vi.fn(async () => { + const handle = testWorker() + if (workers.length === 0) { + handle.dispose.mockImplementation(() => oldDispose) + } + workers.push(handle) + return handle + }) + const consent = fingerprintPluginConsent(manifest()) + const service = new PluginService({ + userDataPath: root, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => ({ [pluginKey]: consent }), + getDevPluginPaths: () => [root], + workerFactory: factory + }) + services.push(service) + await activate(service) + await writeFile( + join(root, 'orca-plugin.json'), + JSON.stringify(manifest({ main: 'worker-v2.js' })) + ) + + const refreshing = service.refresh() + await vi.waitFor(() => expect(workers[0]!.dispose).toHaveBeenCalledOnce()) + const invoking = service.invokeCommand(pluginKey, 'run') + await vi.waitFor(() => expect(factory).toHaveBeenCalledTimes(2)) + expect(factory.mock.calls[1]?.[0].mainEntry).toBe('worker-v2.js') + finishOldDispose() + + await expect(invoking).resolves.toBeNull() + await refreshing + }) + + it('deactivates removed and replaced dev paths without eager activation', async () => { + const firstRoot = await pluginRoot() + const secondRoot = await pluginRoot() + const harness = createHarness(firstRoot) + await activate(harness.service) + + harness.setDevPaths([]) + await harness.service.refresh() + expect(harness.workers[0]!.dispose).toHaveBeenCalledOnce() + expect(harness.service.findValidPlugin(pluginKey)).toBeNull() + + harness.setDevPaths([secondRoot]) + await harness.service.refresh() + expect(harness.factory).toHaveBeenCalledTimes(1) + await harness.service.invokeCommand(pluginKey, 'run') + expect(harness.factory.mock.calls[1]?.[0].rootDir).toBe(secondRoot) + }) + + it('starts and stops housekeeping on feature-flag transitions', async () => { + vi.useFakeTimers() + const root = await pluginRoot() + const harness = createHarness(root) + await harness.service.initialize() + expect(vi.getTimerCount()).toBe(1) + + harness.setEnabled(false) + await harness.service.refresh() + expect(vi.getTimerCount()).toBe(0) + expect(harness.service.getDiscovered()).toEqual([]) + + harness.setEnabled(true) + await harness.service.refresh() + expect(vi.getTimerCount()).toBe(1) + expect(harness.factory).not.toHaveBeenCalled() + }) + + it('serializes activation reconciliation so the latest disabled state wins', async () => { + const root = await pluginRoot() + const harness = createHarness(root) + await harness.service.initialize() + + const originalReconcile = harness.service.contentPacks.reconcile.bind( + harness.service.contentPacks + ) + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + let firstStarted!: () => void + const firstStartedPromise = new Promise((resolve) => { + firstStarted = resolve + }) + let activeReconciliations = 0 + let maximumConcurrentReconciliations = 0 + let callCount = 0 + const reconcile = vi + .spyOn(harness.service.contentPacks, 'reconcile') + .mockImplementation(async (...args) => { + callCount += 1 + activeReconciliations += 1 + maximumConcurrentReconciliations = Math.max( + maximumConcurrentReconciliations, + activeReconciliations + ) + try { + if (callCount === 1) { + firstStarted() + await firstGate + } + await originalReconcile(...args) + } finally { + activeReconciliations -= 1 + } + }) + + const first = harness.service.reconcileActivationState() + await firstStartedPromise + harness.setDisabled([pluginKey]) + const second = harness.service.reconcileActivationState() + let clientsReleased = false + const clientsReady = harness.service.whenReady().then(() => { + clientsReleased = true + }) + + await Promise.resolve() + expect(reconcile).toHaveBeenCalledTimes(1) + expect(clientsReleased).toBe(false) + releaseFirst() + await Promise.all([first, second, clientsReady]) + + expect(maximumConcurrentReconciliations).toBe(1) + expect(clientsReleased).toBe(true) + expect(reconcile).toHaveBeenCalledTimes(2) + expect(harness.service.activationState(harness.service.findValidPlugin(pluginKey)!)).toBe( + 'disabled' + ) + expect(harness.service.workerState(pluginKey).state).toBe('inactive') + }) +}) diff --git a/src/main/plugins/plugin-service.ts b/src/main/plugins/plugin-service.ts new file mode 100644 index 00000000000..9f214cc834b --- /dev/null +++ b/src/main/plugins/plugin-service.ts @@ -0,0 +1,336 @@ +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { + capabilityKinds, + type PluginCapabilityKind +} from '../../shared/plugins/plugin-capabilities' +import { + getPluginActivationState, + type PluginConsentLists +} from '../../shared/plugins/plugin-consent-state' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { + createPluginExtensionRegistry, + type PluginExtensionRegistry +} from '../../shared/plugins/plugin-extension-registry' +import { + discoverPlugins, + getPluginsDataDir, + getUserPluginsDir, + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import { PluginEventBus } from './plugin-event-bus' +import { PluginAuditLog } from './plugin-audit-log' +import { executePluginHostCallRequest } from './plugin-host-call-adapter' +import { PluginContentVerifier } from './plugin-content-integrity' +import { bindPluginHostServices, type PluginRuntimeDelegate } from './plugin-host-service-bindings' +import { PluginLogBuffer, type PluginLogLine } from './plugin-log-buffer' +import { PluginPanelController } from './plugin-panel-controller' +import { PluginWorkerController } from './plugin-worker-controller' +import { PluginServiceHousekeeping } from './plugin-service-housekeeping' +import { collectApprovedWorkerSpecs } from './plugin-worker-reconciliation' +import type { PluginRunState } from './plugin-supervisor' +import { isPluginApproved, snapshotPluginConsentLists } from './plugin-activation-policy' +import { PluginContentPackRegistry } from './plugin-content-pack-registry' +import type { PluginServiceOptions } from './plugin-service-options' +import type { PluginChangeEvent } from '../../shared/plugins/plugin-change-event' +import { waitForPluginRefreshSettlement } from './plugin-refresh-settlement' +import { assertPluginWorkerCommand } from './plugin-command-invocation' +import { deliverPluginEvent } from './plugin-event-delivery' + +export type { PluginRuntimeDelegate } from './plugin-host-service-bindings' +export type { PluginLogLine } from './plugin-log-buffer' +export type { PluginServiceOptions } from './plugin-service-options' + +export class PluginService { + readonly options: PluginServiceOptions + private readonly registry: PluginExtensionRegistry = createPluginExtensionRegistry() + private readonly eventBus = new PluginEventBus() + private readonly audit: PluginAuditLog + private readonly workerController: PluginWorkerController + private readonly logBuffer = new PluginLogBuffer() + private readonly contentVerifier = new PluginContentVerifier() + readonly contentPacks: PluginContentPackRegistry + readonly panels: PluginPanelController + private readonly changeListeners = new Set<(event: PluginChangeEvent) => void>() + private readonly housekeeping = new PluginServiceHousekeeping() + private discovered: DiscoveredPlugin[] = [] + private runtimeDelegate: PluginRuntimeDelegate | null = null + private initPromise: Promise | null = null + private refreshChain: Promise = Promise.resolve() + private contentPacksReady = false + private disposed = false + + constructor(options: PluginServiceOptions) { + this.options = options + this.contentPacks = new PluginContentPackRegistry(this.contentVerifier) + this.audit = new PluginAuditLog(getPluginsDataDir(options.userDataPath)) + this.panels = new PluginPanelController({ + resolveApprovedPlugin: (pluginKey) => { + const plugin = this.findValidPlugin(pluginKey) + return plugin && this.isRuntimeApproved(plugin) ? plugin : null + }, + contentVerifier: this.contentVerifier, + executeHostCall: (pluginKey, method, params) => + this.executeHostCall(pluginKey, method, params, { viaPanel: true }), + log: (pluginKey, line) => this.logBuffer.append(pluginKey, 'error', line) + }) + this.workerController = new PluginWorkerController({ + entryPath: options.hostEntryPath ?? '', + maxActive: options.maxActiveWorkers, + idleReapMs: options.idleReapMs, + workerFactory: options.workerFactory, + registry: this.registry, + contentVerifier: this.contentVerifier, + capabilities: (pluginKey) => this.getGrantedCapabilities(pluginKey), + isCurrentApproved: (plugin) => + this.findValidPlugin(plugin.pluginKey) === plugin && this.isRuntimeApproved(plugin), + invokeCommand: (pluginKey, commandId, args) => this.invokeCommand(pluginKey, commandId, args), + executeHostCall: (pluginKey, method, params) => + this.executeHostCall(pluginKey, method, params, { viaPanel: false }), + log: (pluginKey, level, line) => this.logBuffer.append(pluginKey, level, line), + onStateChanged: () => this.notifyChanged(false), + onWorkerGone: (pluginKey) => this.eventBus.clear(pluginKey) + }) + } + + setRuntimeDelegate(delegate: PluginRuntimeDelegate | null): void { + this.runtimeDelegate = delegate + } + + onChanged(listener: (event: PluginChangeEvent) => void): () => void { + this.changeListeners.add(listener) + return () => this.changeListeners.delete(listener) + } + + private notifyChanged(contentPacksChanged: boolean): void { + for (const listener of this.changeListeners) { + listener({ contentPacksChanged }) + } + } + + async initialize(): Promise { + this.initPromise ??= this.refresh() + return this.initPromise + } + + async whenReady(): Promise { + await (this.initPromise ?? Promise.resolve()).catch(() => undefined) + // Client reads wait for the complete transaction so rollback-based content + // validation cannot expose a partially activated plugin between passes. + await waitForPluginRefreshSettlement(() => this.refreshChain) + } + + refresh(): Promise { + // Snapshot settings at request time so a quick off→on sequence still + // processes the off transition and revokes old workers/panel sessions. + const enabled = this.options.isPluginSystemEnabled() + const devPaths = this.options.getDevPluginPaths() + const consentLists = snapshotPluginConsentLists(this.options) + const refresh = this.refreshChain.then(() => + this.performRefresh(enabled, devPaths, consentLists) + ) + this.refreshChain = refresh.catch(() => undefined) + return refresh + } + + private async performRefresh( + enabled: boolean, + devPaths: string[], + consentLists: PluginConsentLists + ): Promise { + if (this.disposed) { + return + } + this.contentPacksReady = false + this.contentVerifier.clear() + if (!enabled) { + this.panels.revokeAll() + } + const next = enabled + ? await discoverPlugins({ + pluginsDir: getUserPluginsDir(this.options.userDataPath), + devPluginPaths: devPaths, + hostVersion: this.options.hostVersion + }) + : [] + if (this.disposed) { + return + } + // Publish identity before shutdown so triggers cannot restart old code. + this.discovered = next + await this.contentPacks.reconcile( + next, + (plugin) => isPluginApproved(enabled, plugin, consentLists), + this.options.getKeybindings?.() + ) + this.contentPacksReady = true + const nextSpecs = collectApprovedWorkerSpecs(next, (plugin) => this.isRuntimeApproved(plugin)) + // Notify before slow shutdown so feature-off unmounts panels immediately. + this.notifyChanged(true) + await this.workerController.reconcile(nextSpecs) + if (this.disposed) { + return + } + this.housekeeping.sync({ + enabled, + devPaths, + reapIdle: () => this.workerController.reapIdle(), + refresh: () => void this.refresh() + }) + this.notifyChanged(false) + } + + getDiscovered(): readonly DiscoveredPlugin[] { + return this.discovered + } + + getLogs(pluginKey: string): PluginLogLine[] { + return this.logBuffer.get(pluginKey) + } + + findValidPlugin(pluginKey: string): ValidDiscoveredPlugin | null { + for (const plugin of this.discovered) { + if (!isInvalidDiscoveredPlugin(plugin) && plugin.pluginKey === pluginKey) { + return plugin + } + } + return null + } + + activationState(plugin: ValidDiscoveredPlugin): ReturnType { + // The feature flag is an authority boundary, not only a discovery hint: + // callers fail closed immediately even before async reconciliation ends. + if (!this.options.isPluginSystemEnabled()) { + return 'disabled' + } + return getPluginActivationState(plugin.pluginKey, plugin.consentFingerprint, { + pluginConsents: this.options.getPluginConsents(), + disabledPlugins: this.options.getDisabledPlugins() + }) + } + + private isRuntimeApproved(plugin: ValidDiscoveredPlugin): boolean { + return ( + this.contentPacksReady && + this.activationState(plugin) === 'approved' && + !this.contentPacks.error(plugin.pluginKey) && + !this.options.getPluginKillListEntry?.(plugin.pluginKey) + ) + } + + workerState(pluginKey: string): { state: PluginRunState; restarts: number } { + return this.workerController.state(pluginKey) + } + + activationError(pluginKey: string): string | null { + const blocked = this.options.getPluginKillListEntry?.(pluginKey) + return ( + (blocked ? `Blocked by Orca's plugin safety list: ${blocked.reason}` : null) ?? + this.contentPacks.error(pluginKey) ?? + this.workerController.activationError(pluginKey) + ) + } + + /** Consented capability kinds for an approved plugin; null otherwise so + * callers deny uniformly (no probe-able distinction). */ + getGrantedCapabilities(pluginKey: string): PluginCapabilityKind[] | null { + const plugin = this.findValidPlugin(pluginKey) + if (!plugin || !this.isRuntimeApproved(plugin)) { + return null + } + return capabilityKinds(plugin.manifest.capabilities) + } + + /** Host API chokepoint for both transports (worker fork IPC + panel + * bridge); serve RPC reuses it through the same entry points. */ + async executeHostCall( + pluginKey: string, + method: string, + params: unknown, + options: { viaPanel: boolean } + ): Promise { + return executePluginHostCallRequest({ + pluginKey, + request: { method, params }, + viaPanel: options.viaPanel, + resolvePolicy: (boundPluginKey) => ({ + grantedCapabilities: this.getGrantedCapabilities(boundPluginKey), + services: this.runtimeDelegate + ? bindPluginHostServices({ + delegate: this.runtimeDelegate, + pluginsDataDir: getPluginsDataDir(this.options.userDataPath), + subscribeEvents: (key, events) => this.eventBus.subscribe(key, events) + }) + : null, + audit: this.audit + }) + }) + } + + async invokeCommand(pluginKey: string, commandId: string, args?: unknown): Promise { + const plugin = this.findValidPlugin(pluginKey) + if (!plugin || !this.isRuntimeApproved(plugin)) { + throw new Error(`plugin ${pluginKey} is not enabled`) + } + assertPluginWorkerCommand(plugin, commandId) + const handle = await this.workerController.ensure(plugin) + if (!handle.commands.includes(commandId)) { + throw new Error(`plugin ${pluginKey} registered no handler for ${commandId}`) + } + return handle.invokeCommand(commandId, args) + } + + emitEvent(event: PluginEventName, payload: unknown): void { + if (!this.options.isPluginSystemEnabled() || this.disposed) { + return + } + deliverPluginEvent({ + event, + payload, + plugins: this.discovered, + eventBus: this.eventBus, + workerController: this.workerController, + isRuntimeApproved: (plugin) => this.isRuntimeApproved(plugin), + logWarning: (pluginKey, line) => this.logBuffer.append(pluginKey, 'warn', line) + }) + } + + async deactivatePlugin(pluginKey: string): Promise { + await this.workerController.deactivate(pluginKey) + this.notifyChanged(false) + } + + /** Reconciles live workers and client projections after consent or + * enablement changes without re-reading plugin files or starting workers. */ + async reconcileActivationState(): Promise { + const reconcile = this.refreshChain.then(() => this.performActivationStateReconciliation()) + this.refreshChain = reconcile.catch(() => undefined) + return reconcile + } + + private async performActivationStateReconciliation(): Promise { + this.contentPacksReady = false + await this.contentPacks.reconcile( + this.discovered, + (plugin) => this.activationState(plugin) === 'approved', + this.options.getKeybindings?.() + ) + this.contentPacksReady = true + const nextSpecs = collectApprovedWorkerSpecs(this.discovered, (plugin) => + this.isRuntimeApproved(plugin) + ) + await this.workerController.reconcile(nextSpecs) + this.notifyChanged(true) + } + + async dispose(): Promise { + this.disposed = true + this.housekeeping.dispose() + this.panels.dispose() + await this.refreshChain.catch(() => undefined) + await this.workerController.dispose() + await this.audit.flush() + } +} diff --git a/src/main/plugins/plugin-startup-budget.test.ts b/src/main/plugins/plugin-startup-budget.test.ts new file mode 100644 index 00000000000..456aa3822f8 --- /dev/null +++ b/src/main/plugins/plugin-startup-budget.test.ts @@ -0,0 +1,137 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { + PLUGIN_MANIFEST_FILENAME, + pluginManifestSchema, + qualifiedPluginKey, + type PluginManifest +} from '../../shared/plugins/plugin-manifest' +import { PluginService, type PluginServiceOptions } from './plugin-service' +import type { PluginWorkerFactory } from './plugin-worker-manager' + +const PLUGIN_COUNT = 20 +const SAMPLE_COUNT = 20 +// Real disk I/O, so the number moves with machine load: ~16-34ms idle, higher +// when the suite saturates the box. Sized to catch an order-of-magnitude +// regression rather than scheduling noise — the behavioral assertions below +// (no worker spawned, no plugin code run) are what this test really guards. +const STARTUP_P95_BUDGET_MS = 400 + +let userDataPath = '' +let markerPaths: string[] = [] +let consents: Record = {} + +function dummyManifest(index: number): PluginManifest { + const key = String.fromCharCode('A'.charCodeAt(0) + index) + return pluginManifestSchema.parse({ + manifestVersion: 1, + id: `dummy-${index}`, + publisher: 'startup-budget', + name: `Startup Dummy ${index}`, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { + panels: [], + commands: [ + { + id: 'open', + title: `Open Startup Dummy ${index}`, + action: 'view.tasks' + } + ], + events: [], + keybindings: [{ command: 'open', key: `Mod+Alt+${key}` }], + languagePacks: [{ locale: 'pt-BR', path: 'locale.json' }] + }, + capabilities: [] + }) +} + +async function installDummy(index: number): Promise<{ pluginKey: string; markerPath: string }> { + const manifest = dummyManifest(index) + const pluginKey = qualifiedPluginKey(manifest) + const contentHash = (index + 1).toString(16).padStart(64, '0') + const pluginDir = join(userDataPath, 'plugins', pluginKey) + const versionDir = join(pluginDir, contentHash) + const markerPath = join(userDataPath, `activation-${index}.marker`) + await mkdir(versionDir, { recursive: true }) + await Promise.all([ + writeFile(join(pluginDir, 'current'), contentHash), + writeFile(join(versionDir, PLUGIN_MANIFEST_FILENAME), JSON.stringify(manifest)), + writeFile( + join(versionDir, 'locale.json'), + JSON.stringify({ startup: { label: `Startup Dummy ${index}` } }) + ) + ]) + consents[pluginKey] = fingerprintPluginConsent(manifest) + return { pluginKey, markerPath } +} + +function nearestRankP95(samples: readonly number[]): number { + const sorted = [...samples].sort((left, right) => left - right) + return sorted[Math.ceil(sorted.length * 0.95) - 1]! +} + +describe('plugin startup budget', () => { + beforeAll(async () => { + userDataPath = await mkdtemp(join(tmpdir(), 'orca-plugin-startup-budget-')) + const installed = await Promise.all( + Array.from({ length: PLUGIN_COUNT }, (_, index) => installDummy(index)) + ) + markerPaths = installed.map(({ markerPath }) => markerPath) + }) + + afterAll(async () => { + await rm(userDataPath, { recursive: true, force: true }) + }) + + it('stays below 50ms P95 with 20 approved content packs and executes no plugin code', async () => { + const workerFactory = vi.fn(async () => { + throw new Error('startup must not create a plugin worker') + }) + const options: PluginServiceOptions = { + userDataPath, + hostVersion: '1.4.0', + isPluginSystemEnabled: () => true, + getDisabledPlugins: () => [], + getPluginConsents: () => consents, + getDevPluginPaths: () => [], + workerFactory + } + const measure = async (): Promise => { + const startedAt = performance.now() + const service = new PluginService(options) + await service.initialize() + const elapsedMs = performance.now() - startedAt + expect(service.getDiscovered()).toHaveLength(PLUGIN_COUNT) + await service.dispose() + return elapsedMs + } + + await measure() + const samples: number[] = [] + for (let index = 0; index < SAMPLE_COUNT; index += 1) { + samples.push(await measure()) + } + + const p95 = nearestRankP95(samples) + if (process.env.ORCA_PLUGIN_STARTUP_BUDGET_REPORT === '1') { + process.stdout.write(`plugin startup P95 ${p95.toFixed(2)}ms (${SAMPLE_COUNT} samples)\n`) + } + expect(workerFactory).not.toHaveBeenCalled() + await Promise.all( + markerPaths.map((markerPath) => + expect(readFile(markerPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + ) + ) + expect( + p95, + `plugin startup P95 ${p95.toFixed(2)}ms; samples: ${samples.map((sample) => sample.toFixed(2)).join(', ')}` + ).toBeLessThan(STARTUP_P95_BUDGET_MS) + }) +}) diff --git a/src/main/plugins/plugin-storage-store.test.ts b/src/main/plugins/plugin-storage-store.test.ts new file mode 100644 index 00000000000..579fd3f079c --- /dev/null +++ b/src/main/plugins/plugin-storage-store.test.ts @@ -0,0 +1,27 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PLUGIN_STORAGE_VALUE_MAX_BYTES } from '../../shared/plugins/plugin-host-api' +import { PluginKvStore } from './plugin-storage-store' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('PluginKvStore limits', () => { + it('enforces the value cap in UTF-8 bytes rather than JavaScript code units', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-plugin-storage-')) + roots.push(root) + const store = new PluginKvStore(root, 'orca-samples.demo', 'storage.json') + const value = '😀'.repeat(Math.ceil(PLUGIN_STORAGE_VALUE_MAX_BYTES / 4) + 1) + + expect(store.set('large', value)).toMatchObject({ + ok: false, + error: expect.stringContaining('exceeds') + }) + expect(store.get('large')).toBeUndefined() + }) +}) diff --git a/src/main/plugins/plugin-storage-store.ts b/src/main/plugins/plugin-storage-store.ts new file mode 100644 index 00000000000..b3949b8f6d4 --- /dev/null +++ b/src/main/plugins/plugin-storage-store.ts @@ -0,0 +1,102 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { join } from 'node:path' +import { writeSecureFile } from '../../shared/secure-file' +import { isQualifiedPluginKey } from '../../shared/plugins/plugin-manifest' +import { + PLUGIN_STORAGE_KEY_LIMIT, + PLUGIN_STORAGE_TOTAL_MAX_BYTES, + PLUGIN_STORAGE_VALUE_MAX_BYTES +} from '../../shared/plugins/plugin-host-api' + +/** + * Per-plugin JSON key-value persistence backing both `storage.*` (plugin + * data) and `settings.*` (settings:own). Each plugin's data lives in its OWN + * file under `/plugins-data/./` — never a shared + * namespaced blob, so one plugin's path can never resolve into another's. + * Adapted from community PR #5801's per-plugin settings store. + */ + +export function pluginDataDir(pluginsDataDir: string, qualifiedKey: string): string { + if (!isQualifiedPluginKey(qualifiedKey)) { + throw new Error(`unsafe plugin key: ${qualifiedKey}`) + } + return join(pluginsDataDir, qualifiedKey) +} + +export type PluginKvWriteResult = { ok: true } | { ok: false; error: string } + +export class PluginKvStore { + private readonly filePath: string + + constructor( + pluginsDataDir: string, + qualifiedKey: string, + fileName: 'storage.json' | 'settings.json' + ) { + this.filePath = join(pluginDataDir(pluginsDataDir, qualifiedKey), fileName) + } + + private read(): Record { + try { + if (!existsSync(this.filePath)) { + return {} + } + if (statSync(this.filePath).size > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return {} + } + const parsed: unknown = JSON.parse(readFileSync(this.filePath, 'utf8')) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // Corrupt files reset to empty rather than wedging the plugin. + } + return {} + } + + get(key: string): unknown { + return this.read()[key] + } + + getAll(): Record { + return this.read() + } + + keys(): string[] { + return Object.keys(this.read()) + } + + set(key: string, value: unknown): PluginKvWriteResult { + let serialized: string + try { + serialized = JSON.stringify(value) + } catch { + return { ok: false, error: 'value is not JSON-serializable' } + } + if (serialized === undefined) { + return { ok: false, error: 'value is not JSON-serializable' } + } + if (Buffer.byteLength(serialized, 'utf8') > PLUGIN_STORAGE_VALUE_MAX_BYTES) { + return { ok: false, error: `value exceeds ${PLUGIN_STORAGE_VALUE_MAX_BYTES} bytes` } + } + const settings = this.read() + if (!Object.hasOwn(settings, key) && Object.keys(settings).length >= PLUGIN_STORAGE_KEY_LIMIT) { + return { ok: false, error: `storage exceeds the ${PLUGIN_STORAGE_KEY_LIMIT}-key limit` } + } + settings[key] = value + const nextFile = JSON.stringify(settings, null, 2) + if (Buffer.byteLength(nextFile, 'utf8') > PLUGIN_STORAGE_TOTAL_MAX_BYTES) { + return { ok: false, error: `storage exceeds ${PLUGIN_STORAGE_TOTAL_MAX_BYTES} bytes` } + } + writeSecureFile(this.filePath, nextFile) + return { ok: true } + } + + delete(key: string): void { + const settings = this.read() + if (Object.hasOwn(settings, key)) { + delete settings[key] + writeSecureFile(this.filePath, JSON.stringify(settings, null, 2)) + } + } +} diff --git a/src/main/plugins/plugin-supervisor.ts b/src/main/plugins/plugin-supervisor.ts new file mode 100644 index 00000000000..f2aea28cc2a --- /dev/null +++ b/src/main/plugins/plugin-supervisor.ts @@ -0,0 +1,98 @@ +/** + * Crash/restart supervision policy for plugin workers — the pure decision + * half of the runtime. Decides whether a worker that exited should be + * restarted (with backoff) or marked errored after too many crashes. + * + * Pure + deterministic: callers own the actual timers and forking; this just + * tracks per-plugin state and returns decisions. Ported from community PR + * #5801 (gsxdsm). + */ + +export type PluginRunState = 'inactive' | 'running' | 'restarting' | 'errored' + +export type PluginExitInfo = { + /** Clean exit from a host-initiated deactivate vs. an unexpected crash. */ + crashed: boolean +} + +export type PluginRestartDecision = + | { restart: true; delayMs: number; attempt: number } + | { restart: false; state: PluginRunState } + +export type PluginSupervisionConfig = { + /** Max crash-restarts before a plugin is marked errored. `0` means no + * restart attempts — the first crash goes straight to errored. */ + maxRestarts: number + /** Backoff schedule indexed by attempt; the last entry is reused past its + * end. Must be non-empty (enforced in the constructor). */ + backoffMs: number[] +} + +const DEFAULT_CONFIG: PluginSupervisionConfig = { + maxRestarts: 3, + backoffMs: [500, 2000, 5000] +} + +type Entry = { state: PluginRunState; restarts: number } + +export class PluginSupervisor { + private readonly entries = new Map() + private readonly config: PluginSupervisionConfig + + constructor(config: Partial = {}) { + this.config = { ...DEFAULT_CONFIG, ...config } + // Guard misconfiguration: an empty backoff schedule would index [-1] → + // undefined delay (immediate restart loop); a negative cap is meaningless. + if (this.config.maxRestarts < 0) { + throw new Error('PluginSupervisionConfig.maxRestarts must be >= 0') + } + if (this.config.backoffMs.length === 0) { + throw new Error('PluginSupervisionConfig.backoffMs must be non-empty') + } + } + + getState(id: string): PluginRunState { + return this.entries.get(id)?.state ?? 'inactive' + } + + restartCount(id: string): number { + return this.entries.get(id)?.restarts ?? 0 + } + + /** Mark a plugin as running. A fresh activation (not a restart) resets the + * crash counter so a previously-flaky plugin gets a clean slate. */ + markRunning(id: string, options: { resetRestarts?: boolean } = {}): void { + const prior = this.entries.get(id) + this.entries.set(id, { + state: 'running', + restarts: options.resetRestarts ? 0 : (prior?.restarts ?? 0) + }) + } + + /** Record that the worker exited and decide what to do next. */ + markExited(id: string, info: PluginExitInfo): PluginRestartDecision { + if (!info.crashed) { + // Host-initiated stop (or idle reap): go inactive, clear history. + this.entries.set(id, { state: 'inactive', restarts: 0 }) + return { restart: false, state: 'inactive' } + } + const entry = this.entries.get(id) + // An exit for an untracked plugin is not a running crash to restart. + if (!entry) { + return { restart: false, state: 'inactive' } + } + if (entry.restarts >= this.config.maxRestarts) { + this.entries.set(id, { state: 'errored', restarts: entry.restarts }) + return { restart: false, state: 'errored' } + } + const attempt = entry.restarts + 1 + const idx = Math.max(0, Math.min(entry.restarts, this.config.backoffMs.length - 1)) + this.entries.set(id, { state: 'restarting', restarts: attempt }) + return { restart: true, delayMs: this.config.backoffMs[idx]!, attempt } + } + + /** Clear all state (deactivate/remove or a manual re-enable). */ + reset(id: string): void { + this.entries.delete(id) + } +} diff --git a/src/main/plugins/plugin-vm-recipe-registry.test.ts b/src/main/plugins/plugin-vm-recipe-registry.test.ts new file mode 100644 index 00000000000..fd931d79f02 --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-registry.test.ts @@ -0,0 +1,169 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import { hashPluginTree } from './plugin-content-hash' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { PluginVmRecipeRegistry } from './plugin-vm-recipe-registry' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function recipePlugin( + id: string, + artifacts: { path: string; recipe: unknown }[] +): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-vm-recipe-')) + roots.push(rootDir) + await mkdir(join(rootDir, 'recipes')) + await Promise.all( + artifacts.map((artifact) => + writeFile(join(rootDir, artifact.path), JSON.stringify(artifact.recipe), 'utf8') + ) + ) + const manifest = pluginManifestSchema.parse({ + manifestVersion: 1, + id, + publisher: 'orca-samples', + name: id, + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + contributes: { vmRecipes: artifacts.map((artifact) => ({ path: artifact.path })) }, + capabilities: [] + }) + const content = await hashPluginTree(rootDir) + if (!content.ok) { + throw new Error(content.error) + } + return { + pluginKey: `orca-samples.${id}`, + rootDir, + manifest, + consentFingerprint: fingerprintPluginConsent(manifest, content.hash), + consentContentHash: content.hash, + contentHash: null, + isDev: true + } +} + +function artifact(id: string): { + schemaVersion: 1 + id: string + name: string + create: string + suspend: string + resume: string + destroy: string +} { + return { + schemaVersion: 1, + id, + name: `Recipe ${id}`, + create: `create-${id}`, + suspend: `suspend-${id}`, + resume: `resume-${id}`, + destroy: `destroy-${id}` + } +} + +describe('PluginVmRecipeRegistry', () => { + it('retains pending previews and exposes only approved recipes', async () => { + const plugin = await recipePlugin('recipes', [ + { path: 'recipes/cloud.json', recipe: artifact('cloud') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([plugin], () => false) + + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toMatchObject([ + { pluginKey: plugin.pluginKey, recipe: { id: 'cloud', create: 'create-cloud' } } + ]) + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toMatchObject([{ recipe: { id: 'cloud' } }]) + }) + + it('rejects malformed artifacts and duplicate ids within one plugin', async () => { + const malformed = await recipePlugin('malformed', [ + { + path: 'recipes/bad.json', + recipe: { schemaVersion: 1, id: 'bad', name: 'Bad', create: 'create', suspend: 'stop' } + } + ]) + const duplicate = await recipePlugin('duplicate', [ + { path: 'recipes/one.json', recipe: artifact('same') }, + { path: 'recipes/two.json', recipe: artifact('same') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([malformed, duplicate], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.error(malformed.pluginKey)).toContain('suspend and resume') + expect(registry.error(duplicate.pluginKey)).toContain('duplicate VM recipe id') + }) + + it('errors every approved plugin that contributes the same global id', async () => { + const first = await recipePlugin('first', [ + { path: 'recipes/shared.json', recipe: artifact('shared') } + ]) + const second = await recipePlugin('second', [ + { path: 'recipes/shared.json', recipe: artifact('shared') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([first, second], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.error(first.pluginKey)).toContain('multiple plugins') + expect(registry.error(second.pluginKey)).toContain('multiple plugins') + }) + + it('deactivates disabled recipes and removes previews after uninstall', async () => { + const plugin = await recipePlugin('lifecycle', [ + { path: 'recipes/cloud.json', recipe: artifact('cloud') } + ]) + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toHaveLength(1) + + await registry.reconcile([plugin], () => false) + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toHaveLength(1) + + await registry.reconcile([], () => false) + expect(registry.preview(plugin.pluginKey)).toEqual([]) + expect(registry.error(plugin.pluginKey)).toBeNull() + }) + + it('refuses recipe bytes changed after the reviewed content identity', async () => { + const plugin = await recipePlugin('mutable', [ + { path: 'recipes/cloud.json', recipe: artifact('cloud') } + ]) + // Exercise the installed-tree identity as well as mutable dev previews. + plugin.contentHash = plugin.consentContentHash ?? null + const registry = new PluginVmRecipeRegistry() + + await registry.reconcile([plugin], () => true) + expect(registry.list()).toHaveLength(1) + + await writeFile( + join(plugin.rootDir, 'recipes', 'cloud.json'), + JSON.stringify({ ...artifact('cloud'), create: 'changed-after-review' }), + 'utf8' + ) + await registry.reconcile([plugin], () => true) + + expect(registry.list()).toEqual([]) + expect(registry.preview(plugin.pluginKey)).toEqual([]) + expect(registry.error(plugin.pluginKey)).toContain('changed since it was reviewed') + }) +}) diff --git a/src/main/plugins/plugin-vm-recipe-registry.ts b/src/main/plugins/plugin-vm-recipe-registry.ts new file mode 100644 index 00000000000..ac2b90d9b64 --- /dev/null +++ b/src/main/plugins/plugin-vm-recipe-registry.ts @@ -0,0 +1,127 @@ +import type { OrcaVmRecipe } from '../../shared/types' +import { parsePluginVmRecipeArtifact } from '../../shared/plugins/plugin-vm-recipe-artifact' +import { + PLUGIN_VM_RECIPE_MAX_BYTES, + readContainedPluginArtifactText +} from './plugin-artifact-validation' +import { mapWithConcurrency } from '../../shared/map-with-concurrency' +import { + isInvalidDiscoveredPlugin, + type DiscoveredPlugin, + type ValidDiscoveredPlugin +} from './plugin-discovery' +import { verifyInstructionalPluginContent } from './plugin-instructional-content-integrity' + +const VM_RECIPE_LOAD_CONCURRENCY = 4 + +export type PluginVmRecipeRegistration = { + pluginKey: string + recipe: OrcaVmRecipe +} + +type VmRecipeLoadResult = + | { + pluginKey: string + approved: boolean + registrations: PluginVmRecipeRegistration[] + } + | { pluginKey: string; error: string } + +export class PluginVmRecipeRegistry { + private active: PluginVmRecipeRegistration[] = [] + private readonly previews = new Map() + private readonly errors = new Map() + + list(): readonly PluginVmRecipeRegistration[] { + return this.active + } + + preview(pluginKey: string): readonly PluginVmRecipeRegistration[] { + return this.previews.get(pluginKey) ?? [] + } + + error(pluginKey: string): string | null { + return this.errors.get(pluginKey) ?? null + } + + async reconcile( + discovered: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean + ): Promise { + const candidates = discovered.filter( + (plugin): plugin is ValidDiscoveredPlugin => + !isInvalidDiscoveredPlugin(plugin) && plugin.manifest.contributes.vmRecipes.length > 0 + ) + const results = await mapWithConcurrency( + candidates, + VM_RECIPE_LOAD_CONCURRENCY, + async (plugin): Promise => { + try { + const approved = isApproved(plugin) + const registrations: PluginVmRecipeRegistration[] = [] + const seen = new Set() + for (const contribution of plugin.manifest.contributes.vmRecipes) { + const recipe = parsePluginVmRecipeArtifact( + await readContainedPluginArtifactText( + plugin.rootDir, + contribution.path, + PLUGIN_VM_RECIPE_MAX_BYTES + ) + ) + if (seen.has(recipe.id)) { + throw new Error(`duplicate VM recipe id "${recipe.id}"`) + } + seen.add(recipe.id) + registrations.push({ pluginKey: plugin.pluginKey, recipe }) + } + // Verify after reading so the in-memory commands shown/activated are + // bound to the exact tree identity the user reviewed. + await verifyInstructionalPluginContent(plugin) + return { pluginKey: plugin.pluginKey, approved, registrations } + } catch (error) { + return { + pluginKey: plugin.pluginKey, + error: error instanceof Error ? error.message : String(error) + } + } + } + ) + + this.previews.clear() + this.errors.clear() + for (const result of results) { + if ('error' in result) { + this.errors.set(result.pluginKey, result.error) + } else { + this.previews.set(result.pluginKey, result.registrations) + } + } + const approved = results.filter( + (result): result is Extract => + 'approved' in result && result.approved + ) + const owners = new Map>() + for (const result of approved) { + for (const registration of result.registrations) { + const recipeOwners = owners.get(registration.recipe.id) ?? new Set() + recipeOwners.add(result.pluginKey) + owners.set(registration.recipe.id, recipeOwners) + } + } + const conflicted = new Set() + for (const [recipeId, recipeOwners] of owners) { + if (recipeOwners.size > 1) { + for (const pluginKey of recipeOwners) { + conflicted.add(pluginKey) + this.errors.set( + pluginKey, + `VM recipe id "${recipeId}" is contributed by multiple plugins` + ) + } + } + } + this.active = approved + .filter((result) => !conflicted.has(result.pluginKey)) + .flatMap((result) => result.registrations) + } +} diff --git a/src/main/plugins/plugin-worker-controller.test.ts b/src/main/plugins/plugin-worker-controller.test.ts new file mode 100644 index 00000000000..63e05cd6a7b --- /dev/null +++ b/src/main/plugins/plugin-worker-controller.test.ts @@ -0,0 +1,166 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createPluginExtensionRegistry } from '../../shared/plugins/plugin-extension-registry' +import { pluginManifestSchema } from '../../shared/plugins/plugin-manifest' +import type { PluginContentVerifier } from './plugin-content-integrity' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginWorkerHandle } from './plugin-host-process' +import { PluginWorkerController } from './plugin-worker-controller' +import type { PluginWorkerFactory } from './plugin-worker-manager' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +async function plugin(): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-worker-controller-')) + roots.push(rootDir) + await writeFile(join(rootDir, 'main.mjs'), 'export default function activate() {}') + return { + pluginKey: 'orca-samples.demo', + rootDir, + manifest: pluginManifestSchema.parse({ + manifestVersion: 1, + id: 'demo', + publisher: 'orca-samples', + name: 'Demo', + version: '1.0.0', + engines: { orca: '>=1.0.0' }, + pluginApi: 1, + main: 'main.mjs', + contributes: { + panels: [], + commands: [{ id: 'run', title: 'Run' }], + events: [] + }, + capabilities: [] + }), + consentFingerprint: 'sha256-current', + contentHash: null, + isDev: true + } +} + +function worker(commands: string[]): PluginWorkerHandle & { dispose: ReturnType } { + return { + commands, + invokeCommand: vi.fn(async () => null), + deliverEvent: vi.fn(), + lastActivityAt: () => Date.now(), + inFlightCount: () => 0, + dispose: vi.fn(async () => undefined), + kill: vi.fn(), + onExit: vi.fn() + } +} + +function controller(options: { + factory: PluginWorkerFactory + verify: () => Promise + isApproved: () => boolean +}): PluginWorkerController { + return new PluginWorkerController({ + entryPath: '/host-entry.js', + workerFactory: options.factory, + registry: createPluginExtensionRegistry(), + contentVerifier: { verify: options.verify } as unknown as PluginContentVerifier, + capabilities: () => (options.isApproved() ? [] : null), + isCurrentApproved: () => options.isApproved(), + invokeCommand: vi.fn(async () => null), + executeHostCall: vi.fn(async () => ({ ok: true as const, value: null })), + log: vi.fn(), + onStateChanged: vi.fn(), + onWorkerGone: vi.fn() + }) +} + +describe('PluginWorkerController activation authority', () => { + it('does not start code after approval is revoked during integrity verification', async () => { + const subjectPlugin = await plugin() + let approved = true + let finishVerification!: () => void + const verification = new Promise((resolve) => { + finishVerification = resolve + }) + const factory = vi.fn() + const subject = controller({ factory, verify: () => verification, isApproved: () => approved }) + + const activation = subject.ensure(subjectPlugin) + approved = false + finishVerification() + + await expect(activation).rejects.toThrow('no longer approved') + expect(factory).not.toHaveBeenCalled() + await subject.dispose() + }) + + it('disposes a worker whose approval changes while the process starts', async () => { + const subjectPlugin = await plugin() + let approved = true + let finishStart!: (handle: PluginWorkerHandle) => void + const factory = vi.fn( + () => new Promise((resolve) => (finishStart = resolve)) + ) + const subject = controller({ + factory, + verify: async () => undefined, + isApproved: () => approved + }) + const startedWorker = worker(['run']) + + const activation = subject.ensure(subjectPlugin) + await vi.waitFor(() => expect(factory).toHaveBeenCalledOnce()) + approved = false + finishStart(startedWorker) + + await expect(activation).rejects.toThrow('disabled during activation') + expect(startedWorker.dispose).toHaveBeenCalledOnce() + await subject.dispose() + }) + + it('rejects and stops workers that register undeclared commands', async () => { + const subjectPlugin = await plugin() + const startedWorker = worker(['run', 'undeclared']) + const subject = controller({ + factory: vi.fn().mockResolvedValue(startedWorker), + verify: async () => undefined, + isApproved: () => true + }) + + await expect(subject.ensure(subjectPlugin)).rejects.toThrow( + 'registered undeclared command undeclared' + ) + expect(startedWorker.dispose).toHaveBeenCalledOnce() + await subject.dispose() + }) + + it('rejects workers that register declarative action aliases', async () => { + const base = await plugin() + const subjectPlugin: ValidDiscoveredPlugin = { + ...base, + manifest: pluginManifestSchema.parse({ + ...base.manifest, + contributes: { + ...base.manifest.contributes, + commands: [{ id: 'tasks', title: 'Tasks', action: 'view.tasks' }] + } + }) + } + const startedWorker = worker(['tasks']) + const subject = controller({ + factory: vi.fn().mockResolvedValue(startedWorker), + verify: async () => undefined, + isApproved: () => true + }) + + await expect(subject.ensure(subjectPlugin)).rejects.toThrow( + 'registered undeclared command tasks' + ) + expect(startedWorker.dispose).toHaveBeenCalledOnce() + await subject.dispose() + }) +}) diff --git a/src/main/plugins/plugin-worker-controller.ts b/src/main/plugins/plugin-worker-controller.ts new file mode 100644 index 00000000000..de166175b71 --- /dev/null +++ b/src/main/plugins/plugin-worker-controller.ts @@ -0,0 +1,171 @@ +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import type { PluginPanelActionOutcome } from '../../shared/plugins/plugin-panel-bridge' +import { + PLUGIN_COMMAND_EXTENSION_POINT, + type PluginExtensionRegistry +} from '../../shared/plugins/plugin-extension-registry' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import { resolveContainedPluginArtifact } from './plugin-artifact-validation' +import type { PluginContentVerifier } from './plugin-content-integrity' +import { + PluginWorkerManager, + type PluginWorkerFactory, + type PluginWorkerSpawnSpec +} from './plugin-worker-manager' +import { buildPluginWorkerSpawnSpec, pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec' +import type { PluginWorkerHandle } from './plugin-host-process' +import type { PluginRunState } from './plugin-supervisor' + +export type PluginWorkerControllerOptions = { + entryPath: string + maxActive?: number + idleReapMs?: number + workerFactory?: PluginWorkerFactory + registry: PluginExtensionRegistry + contentVerifier: PluginContentVerifier + capabilities: (pluginKey: string) => readonly PluginCapabilityKind[] | null + isCurrentApproved: (plugin: ValidDiscoveredPlugin) => boolean + invokeCommand: (pluginKey: string, commandId: string, args: unknown) => Promise + executeHostCall: ( + pluginKey: string, + method: string, + params: unknown + ) => Promise + log: (pluginKey: string, level: 'info' | 'warn' | 'error', line: string) => void + onStateChanged: (pluginKey: string) => void + onWorkerGone: (pluginKey: string) => void +} + +export class PluginWorkerController { + private readonly manager: PluginWorkerManager + private readonly activationErrors = new Map() + private readonly registeredSpecs = new Map() + + constructor(private readonly options: PluginWorkerControllerOptions) { + this.manager = new PluginWorkerManager({ + entryPath: options.entryPath, + maxActive: options.maxActive, + idleReapMs: options.idleReapMs, + workerFactory: options.workerFactory, + executeHostCall: options.executeHostCall, + log: options.log, + onWorkerStateChange: options.onStateChanged, + onWorkerGone: options.onWorkerGone + }) + } + + state(pluginKey: string): { state: PluginRunState; restarts: number } { + return { + state: this.manager.runState(pluginKey), + restarts: this.manager.restartCount(pluginKey) + } + } + + activationError(pluginKey: string): string | null { + return this.activationErrors.get(pluginKey) ?? null + } + + async ensure(plugin: ValidDiscoveredPlugin): Promise { + if (!plugin.manifest.main) { + throw new Error(`plugin ${plugin.pluginKey} has no worker entry`) + } + try { + this.assertCurrentApproved(plugin) + await this.options.contentVerifier.verify(plugin) + await resolveContainedPluginArtifact(plugin.rootDir, plugin.manifest.main) + this.assertCurrentApproved(plugin) + const capabilities = this.options.capabilities(plugin.pluginKey) + if (!capabilities) { + throw new Error(`plugin ${plugin.pluginKey} is no longer approved`) + } + const spec = buildPluginWorkerSpawnSpec(plugin, capabilities) + const handle = await this.manager.ensureActive(spec) + if (!this.options.isCurrentApproved(plugin)) { + await this.manager.deactivate(plugin.pluginKey) + throw new Error(`plugin ${plugin.pluginKey} changed or was disabled during activation`) + } + const declaredCommands = new Set( + plugin.manifest.contributes.commands + .filter((command) => command.action === undefined) + .map((command) => command.id) + ) + const undeclaredCommand = handle.commands.find((command) => !declaredCommands.has(command)) + if (undeclaredCommand) { + await this.manager.deactivate(plugin.pluginKey) + throw new Error( + `plugin ${plugin.pluginKey} registered undeclared command ${undeclaredCommand}` + ) + } + this.activationErrors.delete(plugin.pluginKey) + this.registerCommands(plugin, spec, handle.commands) + return handle + } catch (error) { + this.activationErrors.set( + plugin.pluginKey, + error instanceof Error ? error.message : String(error) + ) + this.options.onStateChanged(plugin.pluginKey) + throw error + } + } + + private assertCurrentApproved(plugin: ValidDiscoveredPlugin): void { + if (!this.options.isCurrentApproved(plugin)) { + throw new Error(`plugin ${plugin.pluginKey} changed or is no longer approved`) + } + } + + async reconcile(nextSpecs: ReadonlyMap): Promise { + const current = new Map([...this.registeredSpecs, ...this.manager.trackedSpecs()]) + for (const [pluginKey, spec] of current) { + const next = nextSpecs.get(pluginKey) + if (next && pluginWorkerSpawnSpecsEqual(spec, next)) { + continue + } + this.options.registry.clearPlugin(pluginKey) + this.registeredSpecs.delete(pluginKey) + this.activationErrors.delete(pluginKey) + await this.manager.deactivate(pluginKey) + } + } + + async deactivate(pluginKey: string): Promise { + this.options.registry.clearPlugin(pluginKey) + this.registeredSpecs.delete(pluginKey) + this.activationErrors.delete(pluginKey) + await this.manager.deactivate(pluginKey) + } + + reapIdle(): void { + this.manager.reapIdle() + } + + deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void { + this.manager.deliverEventIfRunning(pluginKey, event, payload) + } + + dispose(): Promise { + return this.manager.disposeAll() + } + + private registerCommands( + plugin: ValidDiscoveredPlugin, + spec: PluginWorkerSpawnSpec, + commands: readonly string[] + ): void { + this.options.registry.clearPlugin(plugin.pluginKey) + for (const commandId of commands) { + this.options.registry.register( + PLUGIN_COMMAND_EXTENSION_POINT, + plugin.pluginKey, + { + commandId, + invoke: (args) => this.options.invokeCommand(plugin.pluginKey, commandId, args) + }, + commandId + ) + } + this.registeredSpecs.set(plugin.pluginKey, spec) + } +} diff --git a/src/main/plugins/plugin-worker-env.test.ts b/src/main/plugins/plugin-worker-env.test.ts new file mode 100644 index 00000000000..1e90a0bd180 --- /dev/null +++ b/src/main/plugins/plugin-worker-env.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { buildPluginWorkerEnv } from './plugin-worker-env' + +describe('buildPluginWorkerEnv', () => { + it('matches allowlisted keys case-sensitively on POSIX', () => { + expect( + buildPluginWorkerEnv( + { PATH: '/safe', path: '/wrong', HOME: '/home', NODE_OPTIONS: '--inspect' }, + 'linux' + ) + ).toEqual({ PATH: '/safe', HOME: '/home', ELECTRON_RUN_AS_NODE: '1' }) + }) + + it('matches Windows environment keys case-insensitively', () => { + expect(buildPluginWorkerEnv({ Path: 'C:\\safe', systemroot: 'C:\\Windows' }, 'win32')).toEqual({ + PATH: 'C:\\safe', + SystemRoot: 'C:\\Windows', + ELECTRON_RUN_AS_NODE: '1' + }) + }) +}) diff --git a/src/main/plugins/plugin-worker-env.ts b/src/main/plugins/plugin-worker-env.ts new file mode 100644 index 00000000000..3dbe09b49a2 --- /dev/null +++ b/src/main/plugins/plugin-worker-env.ts @@ -0,0 +1,52 @@ +/** + * Scrubbed environment for plugin workers. Deliberately an allowlist — the + * app's own environment can carry secrets (tokens exported in the user's + * shell, CI credentials); plugins must not inherit it. This intentionally + * diverges from the sidecar precedent, which spreads the full process.env. + */ + +const WORKER_ENV_ALLOWLIST = [ + 'PATH', + 'HOME', + 'USERPROFILE', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'TZ', + 'TMPDIR', + 'TEMP', + 'TMP', + // Why: Windows Node/libuv need these to resolve DLLs and the machine root. + 'SYSTEMROOT', + 'SYSTEMDRIVE', + 'WINDIR', + 'COMSPEC', + 'PATHEXT', + 'PROCESSOR_ARCHITECTURE', + 'NUMBER_OF_PROCESSORS' +] as const + +export function buildPluginWorkerEnv( + baseEnv: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform +): Record { + const env: Record = {} + const windowsLookup = new Map() + if (platform === 'win32') { + // Why: Windows environment keys are case-insensitive, while POSIX keys + // are not; folding on every platform could promote an attacker-set `path`. + for (const [key, value] of Object.entries(baseEnv)) { + if (typeof value === 'string') { + windowsLookup.set(key.toUpperCase(), value) + } + } + } + for (const key of WORKER_ENV_ALLOWLIST) { + const value = platform === 'win32' ? windowsLookup.get(key) : baseEnv[key] + if (value !== undefined) { + env[key === 'SYSTEMROOT' ? 'SystemRoot' : key] = value + } + } + env.ELECTRON_RUN_AS_NODE = '1' + return env +} diff --git a/src/main/plugins/plugin-worker-manager.test.ts b/src/main/plugins/plugin-worker-manager.test.ts new file mode 100644 index 00000000000..932199a7eb5 --- /dev/null +++ b/src/main/plugins/plugin-worker-manager.test.ts @@ -0,0 +1,375 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PluginWorkerHandle } from './plugin-host-process' +import { + PluginWorkerManager, + type PluginWorkerFactory, + type PluginWorkerSpawnSpec +} from './plugin-worker-manager' + +type TestWorker = PluginWorkerHandle & { + exit(code?: number | null): void + dispose: ReturnType Promise>> +} + +function worker(lastActivity = Date.now()): TestWorker { + const exitCallbacks: ((code: number | null) => void)[] = [] + return { + commands: ['run'], + invokeCommand: vi.fn(async () => null), + deliverEvent: vi.fn(), + lastActivityAt: () => lastActivity, + inFlightCount: () => 0, + dispose: vi.fn(async () => undefined), + kill: vi.fn(), + onExit: (callback) => exitCallbacks.push(callback), + exit: (code = 1) => { + for (const callback of exitCallbacks) { + callback(code) + } + } + } +} + +function spec(pluginKey: string): PluginWorkerSpawnSpec { + return { + pluginKey, + rootDir: `/plugins/${pluginKey}`, + mainEntry: 'worker.js', + grantedCapabilities: [] + } +} + +function manager( + factory: PluginWorkerFactory, + options: { maxActive?: number; idleReapMs?: number } = {} +): PluginWorkerManager { + return new PluginWorkerManager({ + entryPath: '/host.js', + workerFactory: factory, + maxActive: options.maxActive, + idleReapMs: options.idleReapMs, + executeHostCall: async () => ({ ok: true, value: null }), + log: vi.fn(), + onWorkerStateChange: vi.fn(), + onWorkerGone: vi.fn() + }) +} + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('PluginWorkerManager capacity', () => { + it('atomically counts in-flight starts against maxActive', async () => { + const starts: { key: string; resolve: (handle: TestWorker) => void }[] = [] + const factory = vi.fn( + ({ pluginId }) => new Promise((resolve) => starts.push({ key: pluginId, resolve })) + ) + const subject = manager(factory, { maxActive: 1 }) + + const first = subject.ensureActive(spec('one')) + const second = subject.ensureActive(spec('two')) + const third = subject.ensureActive(spec('three')) + await flush() + + expect(starts.map((start) => start.key)).toEqual(['one']) + starts[0]!.resolve(worker()) + await first + await subject.deactivate('one') + await flush() + expect(starts.map((start) => start.key)).toEqual(['one', 'two']) + + starts[1]!.resolve(worker()) + await second + await subject.deactivate('two') + await flush() + expect(starts.map((start) => start.key)).toEqual(['one', 'two', 'three']) + starts[2]!.resolve(worker()) + await third + await subject.disposeAll() + }) + + it('removes a cancelled waiter without disturbing FIFO order', async () => { + const starts: { key: string; resolve: (handle: TestWorker) => void }[] = [] + const factory = vi.fn( + ({ pluginId }) => new Promise((resolve) => starts.push({ key: pluginId, resolve })) + ) + const subject = manager(factory, { maxActive: 1 }) + const first = subject.ensureActive(spec('one')) + const cancelled = subject.ensureActive(spec('two')) + const third = subject.ensureActive(spec('three')) + await flush() + starts[0]!.resolve(worker()) + await first + + await subject.deactivate('two') + await expect(cancelled).rejects.toThrow('cancelled') + await subject.deactivate('one') + await flush() + + expect(starts.map((start) => start.key)).toEqual(['one', 'three']) + starts[1]!.resolve(worker()) + await third + await subject.disposeAll() + }) + + it('releases a failed start so the next FIFO waiter can run', async () => { + vi.useFakeTimers() + const secondWorker = worker() + const factory = vi.fn(async ({ pluginId }) => { + if (pluginId === 'one') { + throw new Error('ready failed') + } + return secondWorker + }) + const subject = manager(factory, { maxActive: 1 }) + const first = subject.ensureActive(spec('one')) + const firstSettled = first.catch(() => undefined) + const second = subject.ensureActive(spec('two')) + + await flush() + await expect(second).resolves.toBe(secondWorker) + expect(factory.mock.calls.map(([options]) => options.pluginId)).toEqual(['one', 'two']) + await subject.deactivate('one') + await firstSettled + await subject.disposeAll() + }) + + it('cancels an in-flight start without allowing its generation to land', async () => { + const factory = vi.fn( + ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('factory cancelled')), { + once: true + }) + }) + ) + const subject = manager(factory) + const activation = subject.ensureActive(spec('starting')) + await flush() + + await subject.deactivate('starting') + + await expect(activation).rejects.toThrow('cancelled') + expect(subject.runState('starting')).toBe('inactive') + expect(subject.trackedSpecs().has('starting')).toBe(false) + await subject.disposeAll() + }) + + it('disposes running workers and rejects queued waiters', async () => { + const first = worker() + const factory = vi.fn(async ({ pluginId }) => { + if (pluginId === 'one') { + return first + } + return new Promise(() => undefined) + }) + const subject = manager(factory, { maxActive: 1 }) + await subject.ensureActive(spec('one')) + const queued = subject.ensureActive(spec('two')) + const queuedSettled = queued.catch((error) => error) + await flush() + + await subject.disposeAll() + + expect(first.dispose).toHaveBeenCalledOnce() + await expect(queuedSettled).resolves.toBeInstanceOf(Error) + expect(factory).toHaveBeenCalledTimes(1) + }) +}) + +describe('PluginWorkerManager restart policy', () => { + it('cancels a stale in-flight revision instead of joining it by plugin key', async () => { + const currentWorker = worker() + const factory = vi.fn(({ rootDir, signal }) => { + if (rootDir === '/plugins/old') { + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new Error('old revision cancelled')), { + once: true + }) + }) + } + return Promise.resolve(currentWorker) + }) + const subject = manager(factory) + const oldSpec = { ...spec('demo'), rootDir: '/plugins/old', manifestRevision: 'old' } + const newSpec = { ...spec('demo'), rootDir: '/plugins/new', manifestRevision: 'new' } + + const oldActivation = subject.ensureActive(oldSpec) + await flush() + const currentActivation = subject.ensureActive(newSpec) + + await expect(oldActivation).rejects.toThrow('cancelled') + await expect(currentActivation).resolves.toBe(currentWorker) + expect(factory.mock.calls.map(([options]) => options.rootDir)).toEqual([ + '/plugins/old', + '/plugins/new' + ]) + await subject.disposeAll() + }) + + it('retries startup failures at 500/2000/5000ms before errored', async () => { + vi.useFakeTimers() + const factory = vi.fn(async () => { + throw new Error('not ready') + }) + const subject = manager(factory) + const activation = subject.ensureActive(spec('demo')) + let failure: unknown + const settled = activation.catch((error) => { + failure = error + }) + + await flush() + expect(factory).toHaveBeenCalledTimes(1) + expect(subject.restartCount('demo')).toBe(1) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(499) + expect(factory).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(1) + expect(factory).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(2_000) + expect(factory).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(5_000) + await settled + + expect(factory).toHaveBeenCalledTimes(4) + expect(subject.runState('demo')).toBe('errored') + expect(failure).toBeInstanceOf(Error) + await subject.disposeAll() + }) + + it('joins triggers during backoff without resetting restart history', async () => { + vi.useFakeTimers() + const ready = worker() + const factory = vi.fn(async () => { + if (factory.mock.calls.length === 1) { + throw new Error('first start failed') + } + return ready + }) + const subject = manager(factory) + const first = subject.ensureActive(spec('demo')) + await flush() + const joined = subject.ensureActive(spec('demo')) + + expect(subject.restartCount('demo')).toBe(1) + expect(factory).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(500) + + await expect(Promise.all([first, joined])).resolves.toEqual([ready, ready]) + expect(factory).toHaveBeenCalledTimes(2) + expect(subject.restartCount('demo')).toBe(1) + await subject.disposeAll() + }) + + it('applies the same backoff history to unexpected post-ready exits', async () => { + vi.useFakeTimers() + const workers = [worker(), worker(), worker(), worker()] + const factory = vi.fn(async () => workers[factory.mock.calls.length - 1]!) + const subject = manager(factory) + + await subject.ensureActive(spec('demo')) + workers[0]!.exit(11) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(500) + expect(factory).toHaveBeenCalledTimes(2) + workers[1]!.exit(12) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(2_000) + expect(factory).toHaveBeenCalledTimes(3) + workers[2]!.exit(13) + expect(subject.runState('demo')).toBe('restarting') + await vi.advanceTimersByTimeAsync(5_000) + expect(factory).toHaveBeenCalledTimes(4) + workers[3]!.exit(14) + + expect(subject.runState('demo')).toBe('errored') + expect(subject.restartCount('demo')).toBe(3) + await subject.disposeAll() + }) + + it('cancels a pending restart and never resurrects after deactivate', async () => { + vi.useFakeTimers() + const first = worker() + const factory = vi.fn(async () => first) + const subject = manager(factory) + await subject.ensureActive(spec('demo')) + first.exit() + expect(subject.restartCount('demo')).toBe(1) + + await subject.deactivate('demo') + await vi.advanceTimersByTimeAsync(10_000) + + expect(factory).toHaveBeenCalledTimes(1) + expect(subject.runState('demo')).toBe('inactive') + await subject.disposeAll() + }) +}) + +describe('PluginWorkerManager idle reap', () => { + it('does not reap a worker while an event handler is still in flight', async () => { + const busy = worker(100) + busy.inFlightCount = () => 1 + const subject = manager(vi.fn().mockResolvedValue(busy), { + idleReapMs: 100 + }) + await subject.ensureActive(spec('demo')) + + subject.reapIdle(10_000) + + expect(busy.dispose).not.toHaveBeenCalled() + expect(subject.runState('demo')).toBe('running') + await subject.disposeAll() + }) + + it('disposes an idle worker and activates a fresh generation on demand', async () => { + const first = worker(100) + const second = worker(1_000) + const factory = vi + .fn() + .mockResolvedValueOnce(first) + .mockResolvedValueOnce(second) + const subject = manager(factory, { idleReapMs: 100 }) + await subject.ensureActive(spec('demo')) + + subject.reapIdle(201) + await flush() + expect(first.dispose).toHaveBeenCalledOnce() + expect(subject.runState('demo')).toBe('inactive') + await expect(subject.ensureActive(spec('demo'))).resolves.toBe(second) + await subject.disposeAll() + }) + + it('waits for an in-progress idle shutdown during manager disposal', async () => { + let finishShutdown!: () => void + const idle = worker(100) + idle.dispose.mockImplementation( + () => + new Promise((resolve) => { + finishShutdown = resolve + }) + ) + const subject = manager(vi.fn().mockResolvedValue(idle), { + idleReapMs: 100 + }) + await subject.ensureActive(spec('demo')) + subject.reapIdle(201) + let disposed = false + const disposal = subject.disposeAll().then(() => { + disposed = true + }) + await flush() + expect(disposed).toBe(false) + + finishShutdown() + await disposal + expect(disposed).toBe(true) + }) +}) diff --git a/src/main/plugins/plugin-worker-manager.ts b/src/main/plugins/plugin-worker-manager.ts new file mode 100644 index 00000000000..30e3d0c31dd --- /dev/null +++ b/src/main/plugins/plugin-worker-manager.ts @@ -0,0 +1,309 @@ +import { + PLUGIN_WORKER_IDLE_REAP_MS, + PLUGIN_WORKER_MAX_ACTIVE_DEFAULT +} from '../../shared/plugins/plugin-host-protocol' +import type { PluginEventName } from '../../shared/plugins/plugin-manifest' +import { + PluginSupervisor, + type PluginRestartDecision, + type PluginRunState +} from './plugin-supervisor' +import type { PluginWorkerHandle, PluginWorkerHostCallExecutor } from './plugin-host-process' +import { PluginWorkerSlotPool } from './plugin-worker-slot-pool' +import { + startPluginWorkerAttempt, + type PluginWorkerFactory, + type PluginWorkerSpawnSpec, + type StartedPluginWorker +} from './plugin-worker-startup' +import { runPluginWorkerRestartLoop } from './plugin-worker-restart-loop' +import { pluginWorkerSpawnSpecsEqual } from './plugin-worker-spawn-spec' + +export type { PluginWorkerFactory, PluginWorkerSpawnSpec } from './plugin-worker-startup' + +export type PluginWorkerManagerOptions = { + entryPath: string + maxActive?: number + idleReapMs?: number + workerFactory?: PluginWorkerFactory + executeHostCall: ( + pluginKey: string, + method: string, + params: unknown + ) => ReturnType + log: (pluginKey: string, level: 'info' | 'warn' | 'error', line: string) => void + onWorkerStateChange: (pluginKey: string) => void + onWorkerGone: (pluginKey: string) => void +} + +type ActivationRecord = { + spec: PluginWorkerSpawnSpec + generation: number + controller: AbortController + task: Promise +} + +/** Owns lazy activation, bounded capacity, restart policy, cancellation, and idle reap. */ +export class PluginWorkerManager { + private readonly supervisor = new PluginSupervisor() + private readonly workers = new Map() + private readonly activations = new Map() + private readonly knownSpecs = new Map() + private readonly generations = new Map() + private readonly stoppingWorkers = new Set>() + private readonly slots: PluginWorkerSlotPool + private readonly idleReapMs: number + private disposed = false + + constructor(private readonly options: PluginWorkerManagerOptions) { + this.slots = new PluginWorkerSlotPool(options.maxActive ?? PLUGIN_WORKER_MAX_ACTIVE_DEFAULT) + this.idleReapMs = options.idleReapMs ?? PLUGIN_WORKER_IDLE_REAP_MS + } + + runState(pluginKey: string): PluginRunState { + return this.supervisor.getState(pluginKey) + } + + restartCount(pluginKey: string): number { + return this.supervisor.restartCount(pluginKey) + } + + trackedSpecs(): ReadonlyMap { + return new Map(this.knownSpecs) + } + + async ensureActive(spec: PluginWorkerSpawnSpec): Promise { + if (this.disposed) { + throw new Error('plugin workers are shut down') + } + if (this.supervisor.getState(spec.pluginKey) === 'errored') { + throw new Error(`plugin ${spec.pluginKey} is errored after repeated failures`) + } + for (;;) { + const existing = this.workers.get(spec.pluginKey) + const pending = this.activations.get(spec.pluginKey) + const activeSpec = existing?.spec ?? pending?.spec + if (!activeSpec) { + break + } + if (pluginWorkerSpawnSpecsEqual(activeSpec, spec)) { + return existing?.handle ?? pending!.task + } + // Why: refresh/trigger races can present a new dev manifest while the + // old revision is still starting. Cancel and re-check atomically enough + // that callers never join a stale activation by key alone. + await this.deactivate(spec.pluginKey) + if (this.disposed) { + throw new Error('plugin workers are shut down') + } + } + const generation = this.nextGeneration(spec.pluginKey) + this.knownSpecs.set(spec.pluginKey, spec) + this.supervisor.markRunning(spec.pluginKey, { resetRestarts: true }) + return this.beginActivation(spec, generation) + } + + private beginActivation( + spec: PluginWorkerSpawnSpec, + generation: number, + firstRestart?: Extract + ): Promise { + const controller = new AbortController() + const task = this.activate(spec, generation, controller.signal, firstRestart) + const record: ActivationRecord = { spec, generation, controller, task } + this.activations.set(spec.pluginKey, record) + void task.then( + () => this.finishActivation(spec.pluginKey, record), + () => this.finishActivation(spec.pluginKey, record) + ) + return task + } + + private finishActivation(pluginKey: string, record: ActivationRecord): void { + if (this.activations.get(pluginKey) === record) { + this.activations.delete(pluginKey) + } + } + + private async activate( + spec: PluginWorkerSpawnSpec, + generation: number, + signal: AbortSignal, + firstRestart?: Extract + ): Promise { + return runPluginWorkerRestartLoop({ + signal, + firstRestart, + assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal), + start: async () => { + const worker = await startPluginWorkerAttempt({ + spec, + generation, + signal, + slots: this.slots, + entryPath: this.options.entryPath, + factory: this.options.workerFactory, + executeHostCall: (method, params) => + this.options.executeHostCall(spec.pluginKey, method, params), + log: (level, line) => this.options.log(spec.pluginKey, level, line), + assertActive: () => this.throwIfCancelled(spec.pluginKey, generation, signal), + onExit: (record, code) => this.handleUnexpectedExit(spec.pluginKey, record, code) + }) + this.workers.set(spec.pluginKey, worker) + const earlyExit = worker.completeStart() + if (earlyExit.exited) { + this.detachWorker(spec.pluginKey, worker) + throw new Error(`worker exited immediately after ready (code ${earlyExit.code})`) + } + this.supervisor.markRunning(spec.pluginKey) + this.options.onWorkerStateChange(spec.pluginKey) + return worker.handle + }, + recordFailure: (error) => this.recordFailure(spec.pluginKey, 'worker failed to start', error), + erroredError: (error) => + new Error( + `plugin ${spec.pluginKey} is errored after repeated failures: ${this.errorText(error)}` + ) + }) + } + + private handleUnexpectedExit( + pluginKey: string, + record: StartedPluginWorker, + code: number | null + ): void { + if (!this.detachWorker(pluginKey, record)) { + return + } + if (this.isCancelled(pluginKey, record.generation)) { + return + } + const decision = this.recordFailure(pluginKey, `worker exited unexpectedly (code ${code})`) + if (decision.restart) { + this.beginActivation(record.spec, record.generation, decision) + } + } + + private recordFailure( + pluginKey: string, + context: string, + error?: unknown + ): PluginRestartDecision { + this.options.onWorkerGone(pluginKey) + const decision = this.supervisor.markExited(pluginKey, { crashed: true }) + this.options.onWorkerStateChange(pluginKey) + if (decision.restart) { + this.options.log( + pluginKey, + 'warn', + `${context}${error ? `: ${this.errorText(error)}` : ''}; restart ${decision.attempt} in ${decision.delayMs}ms` + ) + } else if (decision.state === 'errored') { + this.options.log(pluginKey, 'error', `${context}; marked errored after repeated failures`) + } + return decision + } + + private detachWorker(pluginKey: string, record: StartedPluginWorker): boolean { + if (this.workers.get(pluginKey) !== record) { + return false + } + this.workers.delete(pluginKey) + record.lease.release() + return true + } + + deliverEventIfRunning(pluginKey: string, event: PluginEventName, payload: unknown): void { + this.workers.get(pluginKey)?.handle.deliverEvent(event, payload) + } + + async deactivate(pluginKey: string): Promise { + this.nextGeneration(pluginKey) + const activation = this.activations.get(pluginKey) + activation?.controller.abort() + const record = this.workers.get(pluginKey) + if (record) { + this.workers.delete(pluginKey) + } + this.options.onWorkerGone(pluginKey) + this.supervisor.reset(pluginKey) + this.knownSpecs.delete(pluginKey) + await Promise.all([ + activation?.task.catch(() => undefined), + record?.handle.dispose().catch(() => undefined) + ]) + record?.lease.release() + } + + reapIdle(now = Date.now()): void { + for (const [pluginKey, record] of this.workers) { + if ( + record.handle.inFlightCount() !== 0 || + now - record.handle.lastActivityAt() <= this.idleReapMs + ) { + continue + } + this.nextGeneration(pluginKey) + this.knownSpecs.delete(pluginKey) + this.workers.delete(pluginKey) + this.options.onWorkerGone(pluginKey) + this.supervisor.markExited(pluginKey, { crashed: false }) + this.options.log(pluginKey, 'info', 'worker reaped after idle period') + this.options.onWorkerStateChange(pluginKey) + const stopping = record.handle + .dispose() + .catch(() => undefined) + .finally(() => record.lease.release()) + this.stoppingWorkers.add(stopping) + void stopping.then(() => this.stoppingWorkers.delete(stopping)) + } + } + + async disposeAll(): Promise { + this.disposed = true + const pluginKeys = new Set([...this.activations.keys(), ...this.workers.keys()]) + for (const key of pluginKeys) { + this.nextGeneration(key) + this.options.onWorkerGone(key) + } + const activations = [...this.activations.values()] + for (const activation of activations) { + activation.controller.abort() + } + this.slots.dispose() + const workers = [...this.workers.values()] + this.workers.clear() + this.knownSpecs.clear() + const stoppingWorkers = [...this.stoppingWorkers] + await Promise.all([ + ...stoppingWorkers, + ...activations.map((activation) => activation.task.catch(() => undefined)), + ...workers.map(async (record) => { + await record.handle.dispose().catch(() => undefined) + record.lease.release() + }) + ]) + } + + private nextGeneration(pluginKey: string): number { + const generation = (this.generations.get(pluginKey) ?? 0) + 1 + this.generations.set(pluginKey, generation) + return generation + } + + private isCancelled(pluginKey: string, generation: number, signal?: AbortSignal): boolean { + return ( + this.disposed || signal?.aborted === true || this.generations.get(pluginKey) !== generation + ) + } + + private throwIfCancelled(pluginKey: string, generation: number, signal: AbortSignal): void { + if (this.isCancelled(pluginKey, generation, signal)) { + throw new Error('plugin worker activation was cancelled') + } + } + + private errorText(error: unknown): string { + return error instanceof Error ? error.message : String(error) + } +} diff --git a/src/main/plugins/plugin-worker-output-buffer.test.ts b/src/main/plugins/plugin-worker-output-buffer.test.ts new file mode 100644 index 00000000000..074184fdab5 --- /dev/null +++ b/src/main/plugins/plugin-worker-output-buffer.test.ts @@ -0,0 +1,22 @@ +import { PassThrough } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { + PLUGIN_WORKER_OUTPUT_LINE_LIMIT, + pipePluginWorkerOutput +} from './plugin-worker-output-buffer' + +describe('pipePluginWorkerOutput', () => { + it('bounds an unterminated line and resumes after its newline', () => { + const stream = new PassThrough() + const log = vi.fn() + pipePluginWorkerOutput(stream, 'info', log) + + stream.write('x'.repeat(PLUGIN_WORKER_OUTPUT_LINE_LIMIT + 1_000)) + stream.write('discarded') + expect(log).toHaveBeenCalledOnce() + expect(log.mock.calls[0]?.[1]).toHaveLength(PLUGIN_WORKER_OUTPUT_LINE_LIMIT) + + stream.write('\nok\n') + expect(log).toHaveBeenLastCalledWith('info', 'ok') + }) +}) diff --git a/src/main/plugins/plugin-worker-output-buffer.ts b/src/main/plugins/plugin-worker-output-buffer.ts new file mode 100644 index 00000000000..dfbe0478c28 --- /dev/null +++ b/src/main/plugins/plugin-worker-output-buffer.ts @@ -0,0 +1,70 @@ +import type { Readable } from 'node:stream' + +type PluginWorkerOutputSink = (level: 'info' | 'warn' | 'error', line: string) => void + +export const PLUGIN_WORKER_OUTPUT_LINE_LIMIT = 8192 +const TRUNCATION_SUFFIX = '… [truncated]' + +/** Keeps a worker's unterminated output bounded even if it never writes a newline. */ +export function pipePluginWorkerOutput( + stream: Readable | null, + level: 'info' | 'error', + log: PluginWorkerOutputSink +): void { + if (!stream) { + return + } + let buffered = '' + let discarding = false + + function emit(line: string, truncated = false): void { + if (line.trim().length > 0) { + log( + level, + truncated + ? `${line.slice(0, PLUGIN_WORKER_OUTPUT_LINE_LIMIT - TRUNCATION_SUFFIX.length)}${TRUNCATION_SUFFIX}` + : line + ) + } + } + + stream.setEncoding('utf8') + stream.on('data', (chunk: string) => { + let remaining = chunk + while (remaining.length > 0) { + if (discarding) { + const newline = remaining.indexOf('\n') + if (newline < 0) { + return + } + discarding = false + remaining = remaining.slice(newline + 1) + continue + } + const newline = remaining.indexOf('\n') + const segment = newline < 0 ? remaining : remaining.slice(0, newline) + const available = PLUGIN_WORKER_OUTPUT_LINE_LIMIT - buffered.length + if (segment.length > available) { + emit(buffered + segment.slice(0, available), true) + buffered = '' + discarding = newline < 0 + } else { + buffered += segment + if (newline >= 0) { + emit(buffered) + buffered = '' + } + } + if (newline < 0) { + return + } + remaining = remaining.slice(newline + 1) + } + }) + stream.on('end', () => { + if (!discarding) { + emit(buffered) + } + buffered = '' + }) +} diff --git a/src/main/plugins/plugin-worker-reconciliation.ts b/src/main/plugins/plugin-worker-reconciliation.ts new file mode 100644 index 00000000000..21b95e06940 --- /dev/null +++ b/src/main/plugins/plugin-worker-reconciliation.ts @@ -0,0 +1,22 @@ +import { capabilityKinds } from '../../shared/plugins/plugin-capabilities' +import type { DiscoveredPlugin, ValidDiscoveredPlugin } from './plugin-discovery' +import { isInvalidDiscoveredPlugin } from './plugin-discovery' +import type { PluginWorkerSpawnSpec } from './plugin-worker-manager' +import { buildPluginWorkerSpawnSpec } from './plugin-worker-spawn-spec' + +export function collectApprovedWorkerSpecs( + plugins: readonly DiscoveredPlugin[], + isApproved: (plugin: ValidDiscoveredPlugin) => boolean +): ReadonlyMap { + const specs = new Map() + for (const plugin of plugins) { + if (isInvalidDiscoveredPlugin(plugin) || !plugin.manifest.main || !isApproved(plugin)) { + continue + } + specs.set( + plugin.pluginKey, + buildPluginWorkerSpawnSpec(plugin, capabilityKinds(plugin.manifest.capabilities)) + ) + } + return specs +} diff --git a/src/main/plugins/plugin-worker-restart-loop.ts b/src/main/plugins/plugin-worker-restart-loop.ts new file mode 100644 index 00000000000..2c06f6ad724 --- /dev/null +++ b/src/main/plugins/plugin-worker-restart-loop.ts @@ -0,0 +1,50 @@ +import type { PluginRestartDecision } from './plugin-supervisor' + +function cancellationError(): Error { + return new Error('plugin worker activation was cancelled') +} + +function waitForBackoff(delayMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort) + resolve() + }, delayMs) + timer.unref?.() + function onAbort(): void { + clearTimeout(timer) + reject(cancellationError()) + } + signal.addEventListener('abort', onAbort, { once: true }) + if (signal.aborted) { + onAbort() + } + }) +} + +export async function runPluginWorkerRestartLoop(options: { + signal: AbortSignal + firstRestart?: Extract + assertActive: () => void + start: () => Promise + recordFailure: (error: unknown) => PluginRestartDecision + erroredError: (error: unknown) => Error +}): Promise { + let restart = options.firstRestart + for (;;) { + if (restart) { + await waitForBackoff(restart.delayMs, options.signal) + } + options.assertActive() + try { + return await options.start() + } catch (error) { + options.assertActive() + const decision = options.recordFailure(error) + if (!decision.restart) { + throw options.erroredError(error) + } + restart = decision + } + } +} diff --git a/src/main/plugins/plugin-worker-slot-pool.ts b/src/main/plugins/plugin-worker-slot-pool.ts new file mode 100644 index 00000000000..5b86acf38d7 --- /dev/null +++ b/src/main/plugins/plugin-worker-slot-pool.ts @@ -0,0 +1,102 @@ +export type PluginWorkerSlotLease = { + release(): void +} + +type SlotWaiter = { + signal: AbortSignal + resolve: (lease: PluginWorkerSlotLease) => void + reject: (error: Error) => void + onAbort: () => void +} + +function cancellationError(): Error { + return new Error('plugin worker activation was cancelled') +} + +/** Atomically leases the bounded worker slots and hands releases to queued + * activations in FIFO order. */ +export class PluginWorkerSlotPool { + private readonly waiters: SlotWaiter[] = [] + private leased = 0 + private disposed = false + + constructor(private readonly capacity: number) { + if (!Number.isInteger(capacity) || capacity <= 0) { + throw new Error('plugin worker capacity must be a positive integer') + } + } + + acquire(signal: AbortSignal): Promise { + if (this.disposed) { + return Promise.reject(new Error('plugin worker slots are shut down')) + } + if (signal.aborted) { + return Promise.reject(cancellationError()) + } + if (this.leased < this.capacity && this.waiters.length === 0) { + this.leased += 1 + return Promise.resolve(this.createLease()) + } + return new Promise((resolve, reject) => { + let cancelled = false + const waiter: SlotWaiter = { + signal, + resolve, + reject, + onAbort: () => { + if (cancelled) { + return + } + cancelled = true + const index = this.waiters.indexOf(waiter) + if (index >= 0) { + this.waiters.splice(index, 1) + } + signal.removeEventListener('abort', waiter.onAbort) + reject(cancellationError()) + this.drain() + } + } + this.waiters.push(waiter) + signal.addEventListener('abort', waiter.onAbort, { once: true }) + }) + } + + dispose(): void { + if (this.disposed) { + return + } + this.disposed = true + for (const waiter of this.waiters.splice(0)) { + waiter.signal.removeEventListener('abort', waiter.onAbort) + waiter.reject(new Error('plugin worker slots are shut down')) + } + } + + private createLease(): PluginWorkerSlotLease { + let released = false + return { + release: () => { + if (released) { + return + } + released = true + this.leased -= 1 + this.drain() + } + } + } + + private drain(): void { + while (!this.disposed && this.leased < this.capacity && this.waiters.length > 0) { + const waiter = this.waiters.shift()! + waiter.signal.removeEventListener('abort', waiter.onAbort) + if (waiter.signal.aborted) { + waiter.reject(cancellationError()) + continue + } + this.leased += 1 + waiter.resolve(this.createLease()) + } + } +} diff --git a/src/main/plugins/plugin-worker-spawn-spec.ts b/src/main/plugins/plugin-worker-spawn-spec.ts new file mode 100644 index 00000000000..dd12eff372e --- /dev/null +++ b/src/main/plugins/plugin-worker-spawn-spec.ts @@ -0,0 +1,41 @@ +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import type { ValidDiscoveredPlugin } from './plugin-discovery' +import type { PluginWorkerSpawnSpec } from './plugin-worker-startup' + +export function buildPluginWorkerSpawnSpec( + plugin: ValidDiscoveredPlugin, + grantedCapabilities: readonly PluginCapabilityKind[] +): PluginWorkerSpawnSpec { + if (!plugin.manifest.main) { + throw new Error(`plugin ${plugin.pluginKey} has no worker entry`) + } + return { + pluginKey: plugin.pluginKey, + rootDir: plugin.rootDir, + mainEntry: plugin.manifest.main, + // Dev plugins keep one root across manifest edits; include the parsed + // manifest so hot reload cannot reuse a worker with stale contributions. + manifestRevision: JSON.stringify(plugin.manifest), + grantedCapabilities + } +} + +export function pluginWorkerSpawnSpecsEqual( + left: PluginWorkerSpawnSpec, + right: PluginWorkerSpawnSpec +): boolean { + if ( + left.pluginKey !== right.pluginKey || + left.rootDir !== right.rootDir || + left.mainEntry !== right.mainEntry || + left.manifestRevision !== right.manifestRevision + ) { + return false + } + const leftCapabilities = [...left.grantedCapabilities].sort() + const rightCapabilities = [...right.grantedCapabilities].sort() + return ( + leftCapabilities.length === rightCapabilities.length && + leftCapabilities.every((capability, index) => capability === rightCapabilities[index]) + ) +} diff --git a/src/main/plugins/plugin-worker-startup.ts b/src/main/plugins/plugin-worker-startup.ts new file mode 100644 index 00000000000..efc527361f6 --- /dev/null +++ b/src/main/plugins/plugin-worker-startup.ts @@ -0,0 +1,99 @@ +import type { PluginCapabilityKind } from '../../shared/plugins/plugin-capabilities' +import { + startPluginWorker, + type PluginWorkerHandle, + type PluginWorkerHostCallExecutor, + type PluginWorkerLogSink +} from './plugin-host-process' +import type { PluginWorkerSlotLease, PluginWorkerSlotPool } from './plugin-worker-slot-pool' + +export type PluginWorkerSpawnSpec = { + pluginKey: string + rootDir: string + mainEntry: string + manifestRevision?: string + grantedCapabilities: readonly PluginCapabilityKind[] +} + +export type PluginWorkerFactory = (options: { + pluginId: string + rootDir: string + mainEntry: string + entryPath: string + grantedCapabilities: readonly PluginCapabilityKind[] + executeHostCall: PluginWorkerHostCallExecutor + log: PluginWorkerLogSink + signal: AbortSignal +}) => Promise + +export type StartedPluginWorker = { + spec: PluginWorkerSpawnSpec + generation: number + handle: PluginWorkerHandle + lease: PluginWorkerSlotLease + completeStart(): { exited: boolean; code: number | null } +} + +export async function startPluginWorkerAttempt(options: { + spec: PluginWorkerSpawnSpec + generation: number + signal: AbortSignal + slots: PluginWorkerSlotPool + entryPath: string + factory?: PluginWorkerFactory + executeHostCall: PluginWorkerHostCallExecutor + log: PluginWorkerLogSink + assertActive: () => void + onExit: (worker: StartedPluginWorker, code: number | null) => void +}): Promise { + const lease = await options.slots.acquire(options.signal) + let handle: PluginWorkerHandle | null = null + let retained = false + try { + options.assertActive() + const factory = options.factory ?? startPluginWorker + handle = await factory({ + pluginId: options.spec.pluginKey, + rootDir: options.spec.rootDir, + mainEntry: options.spec.mainEntry, + entryPath: options.entryPath, + grantedCapabilities: options.spec.grantedCapabilities, + executeHostCall: options.executeHostCall, + log: options.log, + signal: options.signal + }) + options.assertActive() + let startCompleted = false + let earlyExit = false + let earlyExitCode: number | null = null + const worker: StartedPluginWorker = { + spec: options.spec, + generation: options.generation, + handle, + lease, + completeStart: () => { + startCompleted = true + return { exited: earlyExit, code: earlyExitCode } + } + } + handle.onExit((code) => { + if (!startCompleted) { + earlyExit = true + earlyExitCode = code + return + } + options.onExit(worker, code) + }) + retained = true + return worker + } catch (error) { + if (handle) { + await handle.dispose().catch(() => undefined) + } + throw error + } finally { + if (!retained) { + lease.release() + } + } +} diff --git a/src/main/plugins/plugin-worker-supervision.integration.test.ts b/src/main/plugins/plugin-worker-supervision.integration.test.ts new file mode 100644 index 00000000000..06e41321a91 --- /dev/null +++ b/src/main/plugins/plugin-worker-supervision.integration.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' +import type { PluginWorkerHandle } from './plugin-host-process' +import { PluginWorkerManager, type PluginWorkerSpawnSpec } from './plugin-worker-manager' + +type LogEntry = { + at: number + level: 'info' | 'warn' | 'error' + line: string +} + +const pluginRoots: string[] = [] +const managers: PluginWorkerManager[] = [] +let bundleRoot = '' +let hostEntryPath = '' + +function createStateNotifications(): { + notify: () => void + waitFor: (predicate: () => boolean, description: string, timeoutMs?: number) => Promise +} { + const listeners = new Set<() => void>() + return { + notify: () => { + for (const listener of listeners) { + listener() + } + }, + waitFor: (predicate, description, timeoutMs = 10_000) => { + if (predicate()) { + return Promise.resolve() + } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + listeners.delete(check) + reject(new Error(`timed out waiting for ${description}`)) + }, timeoutMs) + const check = (): void => { + if (!predicate()) { + return + } + clearTimeout(timeout) + listeners.delete(check) + resolve() + } + listeners.add(check) + }) + } + } +} + +beforeAll(async () => { + bundleRoot = await mkdtemp(join(tmpdir(), 'orca-plugin-host-bundle-')) + hostEntryPath = join(bundleRoot, 'plugin-host-entry.cjs') + await build({ + entryPoints: [join(process.cwd(), 'src', 'main', 'plugins', 'plugin-host-entry.ts')], + outfile: hostEntryPath, + bundle: true, + platform: 'node', + target: 'node18', + format: 'cjs', + sourcemap: false, + logLevel: 'silent' + }) +}, 30_000) + +afterEach(async () => { + await Promise.all(managers.splice(0).map((manager) => manager.disposeAll())) + await Promise.all(pluginRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +afterAll(async () => { + if (bundleRoot) { + await rm(bundleRoot, { recursive: true, force: true }) + } +}) + +async function createPluginSpec( + source = `export default function activate(orca) { orca.commands.register('run', async () => ({ ok: true })); }` +): Promise { + const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-supervision-')) + pluginRoots.push(rootDir) + await writeFile(join(rootDir, 'main.mjs'), source) + return { + pluginKey: 'orca-samples.supervision', + rootDir, + mainEntry: 'main.mjs', + grantedCapabilities: [] + } +} + +describe('real plugin worker supervision', () => { + it('terminates and supervises a live worker that disconnects IPC', async () => { + const spec = await createPluginSpec(` + export default function activate(orca) { + orca.commands.register('disconnect', async () => { + process.disconnect?.() + setInterval(() => {}, 1_000) + await new Promise(() => {}) + }) + } + `) + const notifications = createStateNotifications() + const manager = new PluginWorkerManager({ + entryPath: hostEntryPath, + executeHostCall: async () => ({ ok: true, value: null }), + log: vi.fn(), + onWorkerStateChange: notifications.notify, + onWorkerGone: vi.fn() + }) + managers.push(manager) + + const worker = await manager.ensureActive(spec) + const command = worker.invokeCommand('disconnect') + + await expect(command).rejects.toThrow('disconnected') + await notifications.waitFor( + () => manager.runState(spec.pluginKey) === 'restarting', + 'disconnected worker to enter supervised backoff' + ) + expect(manager.restartCount(spec.pluginKey)).toBe(1) + }) + + it('restarts forced exits with 500/2000/5000ms backoff, then stays errored', async () => { + const spec = await createPluginSpec() + const notifications = createStateNotifications() + const logs: LogEntry[] = [] + const manager = new PluginWorkerManager({ + entryPath: hostEntryPath, + executeHostCall: async () => ({ ok: true, value: null }), + log: (_pluginKey, level, line) => logs.push({ at: performance.now(), level, line }), + onWorkerStateChange: notifications.notify, + onWorkerGone: vi.fn() + }) + managers.push(manager) + + let current: PluginWorkerHandle = await manager.ensureActive(spec) + expect(current.commands).toContain('run') + expect(manager.runState(spec.pluginKey)).toBe('running') + + for (const [index, delayMs] of [500, 2_000, 5_000].entries()) { + const exited = current + exited.kill() + await notifications.waitFor( + () => + manager.runState(spec.pluginKey) === 'restarting' && + manager.restartCount(spec.pluginKey) === index + 1, + `restart ${index + 1} to enter backoff` + ) + const restartLog = logs.find((entry) => + entry.line.includes(`restart ${index + 1} in ${delayMs}ms`) + ) + expect(restartLog?.level).toBe('warn') + + current = await manager.ensureActive(spec) + + expect(current).not.toBe(exited) + expect(manager.runState(spec.pluginKey)).toBe('running') + expect(performance.now() - restartLog!.at).toBeGreaterThanOrEqual(delayMs - 25) + } + + current.kill() + await notifications.waitFor( + () => manager.runState(spec.pluginKey) === 'errored', + 'fourth forced exit to become terminally errored' + ) + + expect(manager.restartCount(spec.pluginKey)).toBe(3) + expect( + logs.some((entry) => entry.level === 'error' && entry.line.includes('marked errored')) + ).toBe(true) + await expect(manager.ensureActive(spec)).rejects.toThrow('errored after repeated failures') + }, 45_000) +}) diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index be45aa04ff3..57d7e3f815c 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -141,12 +141,20 @@ describe('PTY provider dispatch', () => { })) as { id: string } expect(result.id).toBe('ssh-pty-1') - expect(mockSshProvider.spawn).toHaveBeenCalledWith({ - cols: 80, - rows: 24, - cwd: undefined, - env: undefined - }) + // Why: the relay host can be launched from a Claude session too, so the stamps are + // stripped on the SSH path as well. Compared as a set — envToDelete is consumed by + // membership only, so a reordering of the merge sources must not fail this. + const sshSpawnArgs = vi.mocked(mockSshProvider.spawn).mock.calls.at(-1)![0] + expect([...(sshSpawnArgs.envToDelete ?? [])].sort()).toEqual( + [ + 'CLAUDE_CODE_CHILD_SESSION', + 'CLAUDE_CODE_SESSION_ID', + 'CLAUDE_CODE_BRIDGE_SESSION_ID' + ].sort() + ) + expect(mockSshProvider.spawn).toHaveBeenCalledWith( + expect.objectContaining({ cols: 80, rows: 24, cwd: undefined, env: undefined }) + ) unregisterSshPtyProvider('conn-123') }) diff --git a/src/main/providers/ssh-filesystem-watch-notifications.test.ts b/src/main/providers/ssh-filesystem-watch-notifications.test.ts new file mode 100644 index 00000000000..3380fe42e89 --- /dev/null +++ b/src/main/providers/ssh-filesystem-watch-notifications.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, it, vi } from 'vitest' +import type { FsChangeEvent } from '../../shared/types' +import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { routeSshFilesystemWatchNotification } from './ssh-filesystem-watch-notifications' +import type { WatchRegistration } from './ssh-filesystem-provider-watch' + +function registration(rootPath: string, ...callbacks: ((events: FsChangeEvent[]) => void)[]) { + return { + rootPath, + callbacks: new Set(callbacks), + terminalCallbacks: new Map(), + remoteWatchId: 1, + ready: true, + stopping: false, + unwatchSent: false + } as unknown as WatchRegistration +} + +function changed(...paths: string[]): FsChangeEvent[] { + return paths.map((absolutePath) => ({ kind: 'update', absolutePath }) as FsChangeEvent) +} + +function route(registrations: Map, events: FsChangeEvent[]): void { + routeSshFilesystemWatchNotification(registrations, 'fs.changed', { events }) +} + +// Reference: the pre-change routing, which called isPathInsideOrEqual per pair and +// so re-normalized the candidate for every root. Not circular for what this asserts +// -- the change under test is that one shared normalization of each candidate still +// matches every root the per-pair normalization did. +function referenceRoute(roots: string[], events: FsChangeEvent[]): Record { + const result: Record = {} + for (const rootPath of roots) { + const matching = events.filter((event) => isPathInsideOrEqual(rootPath, event.absolutePath)) + if (matching.length > 0) { + result[rootPath] = matching.map((event) => event.absolutePath) + } + } + return result +} + +describe('routeSshFilesystemWatchNotification fs.changed fan-out', () => { + it('delivers only the events inside each root', () => { + const alpha = vi.fn() + const beta = vi.fn() + const registrations = new Map([ + ['/repo/alpha', registration('/repo/alpha', alpha)], + ['/repo/beta', registration('/repo/beta', beta)] + ]) + + route( + registrations, + changed( + '/repo/alpha/src/a.ts', + '/repo/beta/src/b.ts', + '/repo/alpha/src/c.ts', + '/elsewhere/d.ts' + ) + ) + + expect(alpha).toHaveBeenCalledTimes(1) + expect(alpha.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ + '/repo/alpha/src/a.ts', + '/repo/alpha/src/c.ts' + ]) + expect(beta.mock.calls[0][0].map((event: FsChangeEvent) => event.absolutePath)).toEqual([ + '/repo/beta/src/b.ts' + ]) + }) + + // Why: the fix hoists normalization out of the inner loop, so the risk is that a + // shared pre-normalized candidate stops matching a root it used to match. + it('routes identically to per-pair normalization', () => { + const roots = ['/repo/alpha', '/repo/alpha-extra', '/repo/beta/', '/repo'] + const events = changed( + '/repo/alpha/src/a.ts', + '/repo/alpha-extra/src/b.ts', + '/repo/beta/src/c.ts', + '/repo/alpha', + '/repo//alpha//src//d.ts', + '/repo/gamma/e.ts', + '/elsewhere/f.ts' + ) + const seen: Record = {} + const registrations = new Map( + roots.map((rootPath) => [ + rootPath, + registration(rootPath, (delivered) => { + seen[rootPath] = delivered.map((event) => event.absolutePath) + }) + ]) + ) + + route(registrations, events) + + expect(seen).toEqual(referenceRoute(roots, events)) + }) + + // Why: normalizeRuntimePathForComparison is not idempotent for WSL UNC paths, so + // a candidate normalized once up front must still match a root normalized once. + it('matches WSL UNC roots whose case survives only a single fold', () => { + const received = vi.fn() + const registrations = new Map([ + ['wsl', registration('\\\\wsl.localhost\\Ubuntu\\home\\User\\Repo', received)] + ]) + + route(registrations, changed('//wsl$/UBUNTU/home/User/Repo/src/a.ts')) + + expect(received).toHaveBeenCalledTimes(1) + expect(received.mock.calls[0][0][0].absolutePath).toBe('//wsl$/UBUNTU/home/User/Repo/src/a.ts') + }) + + // Why: macOS emits NFD names while stored roots are often NFC; both spell the + // same directory, and the shared candidate must still fold into the root. + it('matches a root recorded in NFC against NFD event paths', () => { + const received = vi.fn() + const registrations = new Map([['nfc', registration('/repo/café'.normalize('NFC'), received)]]) + + route(registrations, changed('/repo/café/src/a.ts'.normalize('NFD'))) + + expect(received).toHaveBeenCalledTimes(1) + }) + + it('does not treat a sibling with a shared prefix as inside the root', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + route(registrations, changed('/repo/alphabet/src/a.ts')) + + expect(received).not.toHaveBeenCalled() + }) + + it('delivers the root itself to a watcher on that root', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + route(registrations, changed('/repo/alpha')) + + expect(received).toHaveBeenCalledTimes(1) + }) + + it('notifies every callback registered on a shared root', () => { + const first = vi.fn() + const second = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', first, second)]]) + + route(registrations, changed('/repo/alpha/src/a.ts')) + + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + }) + + it('skips callbacks entirely when no event is inside the root', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + route(registrations, changed('/elsewhere/a.ts', '/repo/beta/b.ts')) + + expect(received).not.toHaveBeenCalled() + }) + + it('ignores methods other than fs.changed and fs.watchFailed', () => { + const received = vi.fn() + const registrations = new Map([['alpha', registration('/repo/alpha', received)]]) + + routeSshFilesystemWatchNotification(registrations, 'pty.data', { + events: changed('/repo/alpha/src/a.ts') + }) + + expect(received).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/ssh-filesystem-watch-notifications.ts b/src/main/providers/ssh-filesystem-watch-notifications.ts index 97e3a3a4cdb..c08afb21095 100644 --- a/src/main/providers/ssh-filesystem-watch-notifications.ts +++ b/src/main/providers/ssh-filesystem-watch-notifications.ts @@ -1,5 +1,8 @@ import type { FsChangeEvent } from '../../shared/types' -import { isPathInsideOrEqual } from '../../shared/cross-platform-path' +import { + createNormalizedPathInsideOrEqualMatcher, + normalizeRuntimePathForComparison +} from '../../shared/cross-platform-path' import { failSshFilesystemWatchRegistration, type WatchRegistration @@ -12,10 +15,17 @@ export function routeSshFilesystemWatchNotification( ): void { if (method === 'fs.changed') { const events = params.events as FsChangeEvent[] + // Why normalize once: isPathInsideOrEqual NFC-normalizes both sides, so the + // nested fan-out re-normalized every event path once per watch root. + const normalizedEvents = events.map((event) => ({ + event, + normalizedPath: normalizeRuntimePathForComparison(event.absolutePath) + })) for (const registration of registrations.values()) { - const matching = events.filter((event) => - isPathInsideOrEqual(registration.rootPath, event.absolutePath) - ) + const isInsideRoot = createNormalizedPathInsideOrEqualMatcher(registration.rootPath) + const matching = normalizedEvents + .filter(({ normalizedPath }) => isInsideRoot(normalizedPath)) + .map(({ event }) => event) if (matching.length > 0) { for (const callback of registration.callbacks) { callback(matching) diff --git a/src/main/providers/ssh-pty-notification-routing.test.ts b/src/main/providers/ssh-pty-notification-routing.test.ts new file mode 100644 index 00000000000..2c1ab863295 --- /dev/null +++ b/src/main/providers/ssh-pty-notification-routing.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest' +import { subscribeSshPtyNotifications } from './ssh-pty-notification-routing' + +type MockMux = { + onNotification: ReturnType +} + +function createSubscription() { + const mux: MockMux = { + onNotification: vi.fn() + } + const dataListeners = new Set<(payload: { id: string; data: string }) => void>() + const replayListeners = new Set<(payload: { id: string; data: string }) => void>() + const exitListeners = new Set<(payload: { id: string; code: number }) => void>() + const livePtyIds = new Set() + const recordExit = vi.fn() + const toAppPtyId = vi.fn((id: string) => `ssh:conn@@${id}`) + + subscribeSshPtyNotifications({ + mux: mux as never, + toAppPtyId, + dataListeners: dataListeners as never, + replayListeners: replayListeners as never, + exitListeners: exitListeners as never, + livePtyIds, + recordExit + }) + + const handler = mux.onNotification.mock.calls[0]?.[0] as ( + method: string, + params: Record + ) => void + if (!handler) { + throw new Error('notification handler was not registered') + } + + return { + handler, + toAppPtyId, + dataListeners, + replayListeners, + exitListeners, + livePtyIds, + recordExit + } +} + +describe('subscribeSshPtyNotifications', () => { + it('ignores non-PTY notifications without mapping params.id', () => { + const { handler, toAppPtyId } = createSubscription() + + expect(() => handler('workspace.changed', { snapshot: { revision: 1 } })).not.toThrow() + expect(() => + handler('fs.changed', { + events: [{ kind: 'update', absolutePath: '/tmp/repo/file.txt' }] + }) + ).not.toThrow() + expect(toAppPtyId).not.toHaveBeenCalled() + }) + + it('routes pty.data after validating the string id', () => { + const { handler, toAppPtyId, dataListeners, livePtyIds } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + + handler('pty.data', { id: 'pty-1', data: 'hello', rawLength: 5, seq: 9 }) + + expect(toAppPtyId).toHaveBeenCalledWith('pty-1') + expect(livePtyIds.has('ssh:conn@@pty-1')).toBe(true) + expect(onData).toHaveBeenCalledWith({ + id: 'ssh:conn@@pty-1', + data: 'hello', + sequenceChars: 5, + seq: 9 + }) + }) + + it('records pty.exit with the validated relay id', () => { + const { handler, exitListeners, livePtyIds, recordExit } = createSubscription() + const onExit = vi.fn() + exitListeners.add(onExit) + livePtyIds.add('ssh:conn@@pty-1') + + handler('pty.exit', { + id: 'pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + + expect(recordExit).toHaveBeenCalledWith('pty-1', 'incarnation-1') + expect(livePtyIds.has('ssh:conn@@pty-1')).toBe(false) + expect(onExit).toHaveBeenCalledWith({ + id: 'ssh:conn@@pty-1', + code: 0, + incarnationId: 'incarnation-1' + }) + }) + + it('ignores PTY methods with missing ids', () => { + const { handler, toAppPtyId, dataListeners } = createSubscription() + const onData = vi.fn() + dataListeners.add(onData) + + expect(() => handler('pty.data', { data: 'orphan' })).not.toThrow() + expect(toAppPtyId).not.toHaveBeenCalled() + expect(onData).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/providers/ssh-pty-notification-routing.ts b/src/main/providers/ssh-pty-notification-routing.ts index 63721877f98..1169674211f 100644 --- a/src/main/providers/ssh-pty-notification-routing.ts +++ b/src/main/providers/ssh-pty-notification-routing.ts @@ -18,9 +18,19 @@ export function subscribeSshPtyNotifications(args: { recordExit: (relayPtyId: string, incarnationId: unknown) => void }): () => void { return args.mux.onNotification((method, params) => { - const id = args.toAppPtyId(params.id as string) + // Why: mux delivers every method to generic handlers; non-PTY payloads + // (workspace.changed, fs.changed, …) have no `id` and must not reach + // toAppPtyId → startsWith. + if (method !== 'pty.exit' && method !== 'pty.data' && method !== 'pty.replay') { + return + } + if (typeof params.id !== 'string' || params.id.length === 0) { + return + } + const relayPtyId = params.id + const id = args.toAppPtyId(relayPtyId) if (method === 'pty.exit') { - args.recordExit(params.id as string, params.incarnationId) + args.recordExit(relayPtyId, params.incarnationId) args.livePtyIds.delete(id) for (const listener of args.exitListeners) { listener({ @@ -33,9 +43,6 @@ export function subscribeSshPtyNotifications(args: { } return } - if (method !== 'pty.data' && method !== 'pty.replay') { - return - } args.livePtyIds.add(id) if (method === 'pty.replay') { for (const listener of args.replayListeners) { diff --git a/src/main/rate-limits/service.test.ts b/src/main/rate-limits/service.test.ts index dfe2755bdc2..245ebed42d2 100644 --- a/src/main/rate-limits/service.test.ts +++ b/src/main/rate-limits/service.test.ts @@ -1751,6 +1751,60 @@ describe('RateLimitService', () => { ) }) + it('caches an outgoing weekly-only Codex account so the switcher keeps its inline bars', async () => { + const service = new RateLimitService() + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-weekly', managedHomePath: '/tmp/account-weekly/home' } + ]) + + const weeklyOnly: ProviderRateLimits = { + provider: 'codex', + session: null, + weekly: { usedPercent: 76, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(weeklyOnly) + .mockResolvedValueOnce(okProvider('codex', 40, Date.now())) + + await service.refresh() + await service.refreshForCodexAccountChange('account-weekly') + + expect(service.getState().inactiveCodexAccounts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + accountId: 'account-weekly', + rateLimits: expect.objectContaining({ + session: null, + weekly: expect.objectContaining({ usedPercent: 76 }) + }) + }) + ]) + ) + }) + + it('does not cache an outgoing Codex account that has no usage windows', async () => { + const service = new RateLimitService() + service.setInactiveCodexAccountsResolver(() => [ + { id: 'account-empty', managedHomePath: '/tmp/account-empty/home' } + ]) + + vi.mocked(fetchClaudeRateLimits).mockResolvedValueOnce(okProvider('claude', 10, Date.now())) + vi.mocked(fetchCodexRateLimits) + .mockResolvedValueOnce(errorProvider('codex', 'codex not signed in')) + .mockResolvedValueOnce(okProvider('codex', 40, Date.now())) + + await service.refresh() + await service.refreshForCodexAccountChange('account-empty') + + expect(service.getState().inactiveCodexAccounts).not.toEqual( + expect.arrayContaining([expect.objectContaining({ accountId: 'account-empty' })]) + ) + }) + it('does not cache host Claude usage under an outgoing WSL account', async () => { const service = new RateLimitService() service.setInactiveClaudeAccountsResolver(() => [ diff --git a/src/main/rate-limits/service.ts b/src/main/rate-limits/service.ts index a1700729ac8..4b0a5e266af 100644 --- a/src/main/rate-limits/service.ts +++ b/src/main/rate-limits/service.ts @@ -378,9 +378,11 @@ export class RateLimitService { target?: CodexAccountSelectionTarget ): Promise { const nextTarget = normalizeCodexAccountSelectionTarget(target) + // Why: weekly-only plans report no session window, so gating on session alone + // dropped their snapshot and left the switcher's inline bars empty. if ( outgoingAccountId && - this.state.codex?.session && + (this.state.codex?.session || this.state.codex?.weekly) && this.isSameCodexTarget(this.codexFetchTarget, nextTarget) ) { this.inactiveCodexCache.set(outgoingAccountId, this.state.codex) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 6d815452211..8486af362eb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1012,6 +1012,7 @@ class InMemoryOrchestrationMessages { this.sequence += 1 const row: MessageRow = { id: `msg_${this.sequence}`, + run_id: 'run_test', from_handle: msg.from, to_handle: msg.to, subject: msg.subject, @@ -7159,6 +7160,67 @@ describe('OrcaRuntimeService', () => { } }) + it('refuses SSH hosts instead of setting the project up on the local machine', async () => { + // Why: both inputs must be paths the pre-guard code would have accepted. An unwritable + // destination fails at mkdir and a non-repo path fails at isGitRepo, which would leave the + // side-effect assertions below unable to observe the local clone/probe they exist to catch. + const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-ssh-guard-')) + const existingFolder = join(destination, 'orca') + mkdirSync(existingFolder, { recursive: true }) + execFileSync('git', ['init'], { cwd: existingFolder, stdio: 'ignore' }) + const spawnSpy = vi.spyOn(gitRunner, 'gitSpawn').mockImplementation(() => { + // Why: unreachable while the guard holds; stubbed so a regression records the call + // instead of shelling out to a real network clone. + const proc = new EventEmitter() as EventEmitter & { stderr: EventEmitter } + proc.stderr = new EventEmitter() + queueMicrotask(() => proc.emit('close', 1, null)) + return proc as never + }) + const repos: Record[] = [] + const runtimeStore = { + ...store, + getRepos: () => [...repos] as never, + addRepo: (repo: Record) => { + repos.push(repo) + } + } + const runtime = new OrcaRuntimeService(runtimeStore as never) + + try { + const cloneError = await runtime + .setupProjectClone({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw', + url: 'https://example.com/orca.git', + destination + }) + .catch((error: unknown) => error) + const existingFolderError = await runtime + .setupProjectExistingFolder({ + projectId: 'github:stablyai/orca', + hostId: 'ssh:openclaw', + path: existingFolder, + kind: 'git' + }) + .catch((error: unknown) => error) + + // Why: the defect was a silent local clone/probe recorded as remote, not a bad message, + // so the absent side effects are asserted before the wording. Both calls are awaited + // first so a regression reports the corruption rather than stopping at the first throw. + expect(spawnSpy).not.toHaveBeenCalled() + expect(repos).toHaveLength(0) + expect(cloneError).toMatchObject({ + message: expect.stringMatching(/SSH hosts are not supported/) + }) + expect(existingFolderError).toMatchObject({ + message: expect.stringMatching(/SSH hosts are not supported/) + }) + } finally { + spawnSpy.mockRestore() + await rm(destination, { recursive: true, force: true }) + } + }) + it('adopts public clone repos into host-qualified project setup', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-runtime-project-clone-')) const clonePath = join(destination, 'orca') @@ -12468,6 +12530,102 @@ describe('OrcaRuntimeService', () => { }) }) + it('observes setup command completion without waiting for its interactive shell to exit', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-setup' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + ;( + runtime as unknown as { setupCompletionTokenByPtyId: Map } + ).setupCompletionTokenByPtyId.set('pty-setup', 'token-live') + + const waiting = runtime.waitForSetupTerminalCompletion(handle) + runtime.onPtyData( + 'pty-setup', + 'setup failed\r\n__ORCA_SETUP_COMPLETE__:token-live:17\r\nPS>', + 100 + ) + + await expect(waiting).resolves.toEqual({ exitCode: 17 }) + await expect(runtime.readTerminal(handle)).resolves.toMatchObject({ status: 'running' }) + }) + + it('replays fast setup completion emitted before its observer is registered', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-fast-setup' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + ;( + runtime as unknown as { setupCompletionTokenByPtyId: Map } + ).setupCompletionTokenByPtyId.set('pty-fast-setup', 'token-fast') + runtime.onPtyData( + 'pty-fast-setup', + '__ORCA_SETUP_COMPLETE__:wrong:9\r\n__ORCA_SETUP_COMPLETE__:token-fast:0\r\n$', + 100 + ) + + await expect(runtime.waitForSetupTerminalCompletion(handle)).resolves.toEqual({ exitCode: 0 }) + }) + + it('falls back to setup terminal exit when no completion signal is available', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-legacy-setup' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + const waiting = runtime.waitForSetupTerminalCompletion(handle) + runtime.onPtyExit('pty-legacy-setup', 9) + + await expect(waiting).resolves.toEqual({ exitCode: 9 }) + }) + + it('keeps observing after an uncertain setup terminal status', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-uncertain-setup' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + ;( + runtime as unknown as { setupCompletionTokenByPtyId: Map } + ).setupCompletionTokenByPtyId.set('pty-uncertain-setup', 'token-uncertain') + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle, + condition: 'exit', + satisfied: false, + status: 'unknown', + exitCode: null + }) + + const waiting = runtime.waitForSetupTerminalCompletion(handle) + await Promise.resolve() + runtime.onPtyData('pty-uncertain-setup', '__ORCA_SETUP_COMPLETE__:token-uncertain:0\r\n', 100) + + await expect(waiting).resolves.toEqual({ exitCode: 0 }) + }) + it('drops retained PTY transcript memory when a background terminal exits', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -17290,6 +17448,83 @@ describe('OrcaRuntimeService', () => { expect(writes).toEqual(['still writable']) }) + it('preserves runtime-created PTY process identity after graph unavailable', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const incarnation = runtime.getTerminalProcessIncarnation(handle) + + runtime.markGraphUnavailable(1) + + expect(runtime.getTerminalProcessIncarnation(handle)).toBe(incarnation) + }) + + it('preserves PTY process identity while a renderer surface detaches and reattaches', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ + id: 'pty-bg', + incarnationId: 'incarnation-bg' + }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + const [tabId, leafId] = created.paneKey?.split(':') ?? [] + if (!tabId || !leafId) { + throw new Error('expected stable pane identity') + } + const syncSurface = (ptyId: string | null): void => { + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + title: 'Codex', + activeLeafId: leafId, + layout: null + } + ], + leaves: [ + { + tabId, + worktreeId: TEST_WORKTREE_ID, + leafId, + paneRuntimeId: 1, + ptyId, + paneTitle: 'Codex' + } + ] + }) + } + + syncSurface('pty-bg') + await runtime.listTerminals() + const before = runtime.getTerminalProcessIncarnation(created.handle) + syncSurface(null) + syncSurface('pty-bg') + await runtime.listTerminals() + + expect(runtime.getTerminalProcessIncarnation(created.handle)).toBe(before) + + runtime.registerPty('pty-bg', TEST_WORKTREE_ID, null, { + tabId, + leafId, + incarnationId: 'incarnation-replacement' + }) + expect(runtime.getTerminalProcessIncarnation(created.handle)).not.toBe(before) + }) + it('recognizes runtime-created PTY handles with agent launch titles', async () => { const runtime = new OrcaRuntimeService(store) runtime.setPtyController({ @@ -18904,6 +19139,333 @@ describe('OrcaRuntimeService', () => { expect(result.tabs[0]).not.toHaveProperty('launchAgent') }) + it('publishes the hook provider session on a headless mobile tab so native chat can address the transcript', async () => { + const paneKey = makePaneKey('claude-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: '7dd0c22c-0ff6-45bf-b88a-cea11c34d073', + transcriptPath: '/transcripts/7dd0c22c.jsonl' + } + const runtime = new OrcaRuntimeService(store, undefined, { + // Headless serve has no renderer, so the hook snapshot is the only carrier. + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'claude-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-claude' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'claude-tab', + leafId: HEADLESS_LEAF_ID, + launchAgent: 'claude', + title: 'Terminal' + }) + + runtime.onPtyData('pty-claude', '\x1b]0;✳ Claude Code\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'claude', providerSession }) + }) + ) + }) + + it('recovers the agent type from the hook row when the pane was launched without an agent hint', async () => { + // A user who types `claude` in a plain terminal leaves no launchAgent, and headless + // has no renderer to publish one; without the hook's agentType mobile treats the tab + // as a non-agent terminal and hides native chat even though the session is addressable. + const paneKey = makePaneKey('shell-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: 'ac1f6b90-2f77-4f0e-9c5e-1d2f6a4b8c31', + transcriptPath: '/transcripts/ac1f6b90.jsonl' + } + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'shell-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-shell' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'shell-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-shell', '\x1b]0;✳ Claude Code\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'claude', providerSession }) + }) + ) + }) + + it('reads one agent-status snapshot per projection, not one per terminal tab', async () => { + // The getter rebuilds every known pane's payload on each call, so reading it + // inside the per-tab loop made a projection O(tabs x panes) of pure garbage — + // worst in headless serve, where every terminal tab takes the hook fallback. + let snapshotReads = 0 + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentProviderSessionSnapshot: () => { + snapshotReads += 1 + return [] + } + }) + runtime.setPtyController({ + spawn: vi.fn(async () => ({ id: `pty-${snapshotReads}-${Math.random()}` })), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + for (const tabId of ['fan-a', 'fan-b', 'fan-c']) { + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId, + leafId: HEADLESS_LEAF_ID, + launchAgent: 'claude', + title: 'Terminal' + }) + } + snapshotReads = 0 + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + // Guards the assertion below from passing vacuously on a one-tab projection. + expect(result.tabs.filter((tab) => tab.type === 'terminal').length).toBeGreaterThan(1) + expect(snapshotReads).toBe(1) + }) + + it('publishes hook-only identity for a pane that never emitted an agent title', async () => { + // The hook row is the whole evidence here: no launchAgent hint, no recognized OSC + // title, so `pty.lastAgentStatus` stays unset. Gating the hook read behind that + // made the headless carrier unreachable in exactly the case it exists for. + const paneKey = makePaneKey('quiet-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: 'b91c7e40-5a2d-4f19-9c33-2a7b6e5d4c88', + transcriptPath: '/transcripts/b91c7e40.jsonl' + } + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: Date.now(), + stateStartedAt: Date.now(), + tabId: 'quiet-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-quiet' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'quiet-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'claude', providerSession }) + }) + ) + }) + + it('reads a resume-identity-only row the live-agent snapshot filters out', async () => { + // Pi publishes its session separately from status, and the shared getter drops + // those rows so they can't read as running agents — leaving native chat with no + // transcript to address unless the unfiltered snapshot is consulted too. + const paneKey = makePaneKey('pi-tab', HEADLESS_LEAF_ID) + const providerSession = { + key: 'session_id' as const, + id: '/sessions/pi-1.json', + transcriptPath: '/sessions/pi-1.json' + } + const now = Date.now() + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentProviderSessionSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: '', + agentType: 'pi', + connectionId: null, + receivedAt: now + 1, + stateStartedAt: now + 1, + tabId: 'pi-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession, + providerSessionOnly: true + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-pi' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.objectContaining({ agentType: 'pi', providerSession }) + }) + ) + }) + + it('does not let stale Pi resume metadata claim a plain terminal', async () => { + const paneKey = makePaneKey('stale-pi-tab', HEADLESS_LEAF_ID) + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentProviderSessionSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: '', + agentType: 'pi', + connectionId: null, + receivedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1, + stateStartedAt: Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1, + tabId: 'stale-pi-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession: { + key: 'session_id', + id: '/sessions/stale-pi.json', + transcriptPath: '/sessions/stale-pi.json' + }, + providerSessionOnly: true + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-stale-pi' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'stale-pi-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + expect(result.tabs[0]).toEqual( + expect.objectContaining({ + type: 'terminal', + agentStatus: expect.not.objectContaining({ agentType: 'pi' }) + }) + ) + }) + + it('does not claim a stale hook agent owns a pane whose agent has since exited', async () => { + // `pty.lastAgentStatus` outlives the agent, so an unbounded hook read would keep + // offering mobile native chat for what is now a plain shell — and point it at a + // dead transcript. The session id may stay; the ownership claim must not. + const paneKey = makePaneKey('exited-tab', HEADLESS_LEAF_ID) + const staleReceivedAt = Date.now() - AGENT_STATUS_STALE_AFTER_MS - 1_000 + const runtime = new OrcaRuntimeService(store, undefined, { + getAgentStatusSnapshot: () => [ + { + paneKey, + state: 'done', + prompt: 'Hi', + agentType: 'claude', + connectionId: null, + receivedAt: staleReceivedAt, + stateStartedAt: staleReceivedAt, + tabId: 'exited-tab', + worktreeId: TEST_WORKTREE_ID, + providerSession: { + key: 'session_id' as const, + id: 'd4c3b2a1-0000-4000-8000-000000000001', + transcriptPath: '/transcripts/d4c3b2a1.jsonl' + } + } + ] + }) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-exited' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + await runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, { + tabId: 'exited-tab', + leafId: HEADLESS_LEAF_ID, + title: 'Terminal' + }) + + runtime.onPtyData('pty-exited', '\x1b]0;✳ Claude Code\x07', 123) + await new Promise((resolve) => setImmediate(resolve)) + + const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) + + const tab = result.tabs[0] + expect(tab?.type).toBe('terminal') + const agentStatus = tab && 'agentStatus' in tab ? tab.agentStatus : null + expect(agentStatus?.agentType ?? null).toBeNull() + }) + it('waits for unknown-launch foreground owner before publishing Pi-compatible mobile status', async () => { const foregroundProcess = deferred() const spawn = vi.fn().mockResolvedValue({ id: 'pty-typed-omp' }) @@ -26781,7 +27343,7 @@ describe('OrcaRuntimeService', () => { const waitPromise = runtime.waitForMessage('term_abc', { timeoutMs: 5000 }) runtime.notifyMessageArrived('term_abc') - await waitPromise + await expect(waitPromise).resolves.toBe('notified') }) it('does not resolve type-filtered message waiters for unrelated message types', async () => { @@ -26822,13 +27384,38 @@ describe('OrcaRuntimeService', () => { }) it('resolves message waiters on timeout when no message arrives', async () => { - const runtime = new OrcaRuntimeService(store) + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + const wait = runtime.waitForMessage('term_abc', { timeoutMs: 100 }) - const start = Date.now() - await runtime.waitForMessage('term_abc', { timeoutMs: 100 }) - const elapsed = Date.now() - start - expect(elapsed).toBeGreaterThanOrEqual(90) - expect(elapsed).toBeLessThan(500) + await vi.advanceTimersByTimeAsync(99) + let settled = false + void wait.then(() => { + settled = true + }) + await Promise.resolve() + expect(settled).toBe(false) + + await vi.advanceTimersByTimeAsync(1) + await expect(wait).resolves.toBe('timed_out') + } finally { + vi.useRealTimers() + } + }) + + it('allows only one exclusive mailbox waiter and supports explicit cancellation', async () => { + const runtime = new OrcaRuntimeService(store) + const first = runtime.waitForMessage('run:run_1', { + timeoutMs: 5000, + exclusive: true + }) + + await expect( + runtime.waitForMessage('run:run_1', { timeoutMs: 5000, exclusive: true }) + ).resolves.toBe('waiter_exists') + runtime.cancelMessageWaiters('run:run_1') + await expect(first).resolves.toBe('cancelled') }) it('rejects leaf PTY waits when the request signal aborts', async () => { @@ -32581,11 +33168,13 @@ describe('OrcaRuntimeService', () => { } ]) - await runtime.createManagedWorktree({ + const result = await runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'runtime-headless-parallel', setupDecision: 'run', - startup: { command: 'claude' } + startup: { command: 'claude' }, + observeSetupCompletion: true, + awaitTerminalProvisioning: true }) // Why: setup now spawns fire-and-forget on a later tick; wait for both PTYs. @@ -32593,8 +33182,14 @@ describe('OrcaRuntimeService', () => { expect(spawn).toHaveBeenNthCalledWith(1, expect.objectContaining({ command: 'claude' })) expect(spawn).toHaveBeenNthCalledWith( 2, - expect.objectContaining({ command: 'bash /tmp/repo/.git/orca/setup-runner.sh' }) + expect.objectContaining({ + command: expect.stringContaining('__ORCA_SETUP_COMPLETE__:') + }) ) + expect(result.setupReceipt).toMatchObject({ + state: 'running', + terminalHandle: expect.stringMatching(/^term_/) + }) }) it('creates the first terminal for CLI-created worktrees without activating them', async () => { @@ -32806,10 +33401,12 @@ describe('OrcaRuntimeService', () => { const result = await runtime.createManagedWorktree({ repoSelector: 'id:repo-1', name: 'runtime-cli-setup-skip', - setupDecision: 'skip' + setupDecision: 'skip', + awaitTerminalProvisioning: true }) expect(result.warning).toBeUndefined() + expect(result.setupReceipt).toMatchObject({ requested: 'skip', state: 'skipped' }) expect(createSetupRunnerScript).not.toHaveBeenCalled() expect(spawn).toHaveBeenCalledTimes(1) }) @@ -33367,7 +33964,8 @@ describe('OrcaRuntimeService', () => { name: 'runtime-startup-setup-split', startupDraft: 'https://github.com/stablyai/orca/issues/123', setupDecision: 'run', - activate: true + activate: true, + awaitTerminalProvisioning: true }) await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) @@ -33405,6 +34003,10 @@ describe('OrcaRuntimeService', () => { const mainEnv = (spawn.mock.calls[0]![0] as { env?: Record }).env ?? {} const setupEnv = (spawn.mock.calls[1]![0] as { env?: Record }).env ?? {} expect(result.setup).toBeUndefined() + expect(result.setupReceipt).toMatchObject({ + state: 'running', + terminalHandle: expect.stringMatching(/^term_/) + }) expect(mainEnv.ORCA_TAB_ID).toBeDefined() expect(mainEnv.ORCA_PANE_KEY).toBeDefined() expect(setupEnv.ORCA_TAB_ID).toBe(mainEnv.ORCA_TAB_ID) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 6e66cda4e60..03df14bebf6 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -35,6 +35,7 @@ import { type AgentStatusOrchestrationContext, type AgentStatusEntry } from '../../shared/agent-status-types' +import { indexAgentStatusRowsByPaneKey } from '../agent-hooks/agent-status-pane-index' import type { AgentSessionClaimedSpawnResult, AgentSessionExecutionClaim, @@ -95,7 +96,23 @@ import { mkdir, readFile, readdir, rm, stat } from 'node:fs/promises' import { resolveWorktreeCreateBase } from '../worktree-create-base' import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref' import { OrchestrationDb } from './orchestration/db' +import { OrchestrationError } from './orchestration/orchestration-error' +import { + buildObservedSetupCommand, + createSetupCompletionScanner +} from './orchestration/setup-completion-signal' +import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' +import { + isOrchestrationMutation, + orchestrationMigrationData +} from '../../shared/orchestration-rpc-contract' +import type { + OrchestrationEnvironmentTransport, + OrchestrationWorkerServer +} from './orchestration/environment-transport' +import { syncFederatedDispatch } from './orchestration/federation-sync' import { formatMessagesForInjection } from './orchestration/formatter' +import { selectExactWorkerProviderSession } from './orchestration/worker-provider-session' import type { Automation, AutomationCreateInput, @@ -182,6 +199,7 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' +import type { ExactWorkerProviderSession } from '../../shared/orchestration-worker-output' import type { RuntimeClientEvent } from '../../shared/runtime-client-events' import { toRuntimeActivateWorktreeEvent } from '../../shared/runtime-client-events' import { @@ -331,6 +349,8 @@ import { BROWSER_HEADLESS_RUNTIME_CAPABILITY, BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY, MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_CONTRACT_VERSION, REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY, RUNTIME_CAPABILITIES, RUNTIME_PROTOCOL_VERSION, @@ -472,7 +492,7 @@ import { } from '../../shared/claude-agent-teams-tmux-compat' import { joinWorktreeRelativePath } from './runtime-relative-paths' import { collectMemorySnapshot } from '../memory/collector' -import { BrowserWindow, ipcMain } from 'electron' +import { BrowserWindow, ipcMain, Notification } from 'electron' import type { AgentBrowserBridge } from '../browser/agent-browser-bridge' import type { BrowserBackend } from '../browser/browser-backend' import { BrowserError } from '../browser/cdp-bridge' @@ -1697,11 +1717,13 @@ type TerminalWaiter = { type MessageWaiter = { handle: string typeFilter: string[] | undefined - resolve: (result: void) => void + resolve: (result: MessageWaitResult) => void timeout: NodeJS.Timeout | null abortCleanup: (() => void) | null } +export type MessageWaitResult = 'notified' | 'timed_out' | 'cancelled' | 'waiter_exists' + function omitUndefinedProperties>(value: T): Partial { return Object.fromEntries( Object.entries(value).filter(([, entry]) => entry !== undefined) @@ -1801,6 +1823,19 @@ function runtimeRepoMatchesExecutionHost( return repo.connectionId == null } +// Why: this runtime only has local git and local fs, so an ssh: host here would clone and +// probe the wrong machine and then register the result as remote. SSH setup is owned by the +// desktop IPC path (addRemoteRepoFromPath / cloneRemoteRepo), which the renderer routes to; +// only `local` and `runtime:` legitimately reach these RPCs. +function assertProjectHostSetupHostIsSupported(hostId: ExecutionHostId | null | undefined): void { + if (parseExecutionHostId(hostId)?.kind !== 'ssh') { + return + } + throw new Error( + 'SSH hosts are not supported by this operation. Set the project up from the Orca desktop app, which owns the SSH connection.' + ) +} + function getRuntimeFolderWorkspaceInstanceId(repo: Repo, instanceId: string): string { return `${getRuntimeFolderWorkspaceRootId(repo)}${FOLDER_WORKSPACE_INSTANCE_SEPARATOR}${instanceId}` } @@ -2346,7 +2381,7 @@ type ResolvedWorktreeInFlight = { // events after it — idempotent, no duplicate local pushes. export type MobileNotificationDispatchEvent = { type: 'notification' - source: 'agent-task-complete' | 'terminal-bell' | 'test' + source: 'agent-task-complete' | 'terminal-bell' | 'test' | 'plugin' title: string body: string worktreeId?: string @@ -2354,6 +2389,10 @@ export type MobileNotificationDispatchEvent = { notificationSeq?: number } +export type RuntimeWorktreeLifecycleEvent = + | { kind: 'created'; worktreeId: string; path: string; branch: string } + | { kind: 'removed'; worktreeId: string; path: string } + export type MobileNotificationDismissEvent = { type: 'dismiss' notificationId: string @@ -2433,6 +2472,10 @@ export class OrcaRuntimeService { private readonly runtimeId = randomUUID() private readonly startedAt = Date.now() private readonly store: RuntimeStore | null + private readonly orchestrationEnvironmentTransport: OrchestrationEnvironmentTransport | null + private readonly orchestrationFederationTimers = new Map>() + private readonly orchestrationFederationSyncs = new Map>() + private readonly orchestrationFederationWarnings = new Set() private rendererGraphEpoch = 0 private graphStatus: RuntimeGraphStatus = 'unavailable' private authoritativeWindowId: number | null = null @@ -2526,6 +2569,7 @@ export class OrcaRuntimeService { private ptyController: RuntimePtyController | null = null private notifier: RuntimeNotifier | null = null private clientEventListeners = new Set<(event: RuntimeClientEvent) => void>() + private worktreeLifecycleListeners = new Set<(event: RuntimeWorktreeLifecycleEvent) => void>() private forkBackfillStarted = false private agentBrowserBridge: AgentBrowserBridge | null = null private offscreenBrowserBackend: BrowserBackend | null = null @@ -2557,6 +2601,7 @@ export class OrcaRuntimeService { // Why: startup draft paste can subscribe after the agent already emitted its // ready marker. Keep a bounded raw buffer so fast startup output is replayed. private recentPtyOutputById = new Map() + private setupCompletionTokenByPtyId = new Map() // Why: mobile clients need to know when the desktop restores a terminal // from mobile-fit so they can update their UI. These listeners are // invoked from resizeForClient and onClientDisconnected/onPtyExit. @@ -2906,6 +2951,10 @@ export class OrcaRuntimeService { private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null private terminalSideEffectConsumerAvailable = false private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentProviderSessionSnapshotFn: (() => AgentStatusIpcPayload[]) | null + private readonly getAgentProviderSessionRowsForPaneFn: + | ((paneKey: string) => AgentStatusIpcPayload[]) + | null private readonly buildAgentHookPtyEnv: (() => Record) | null private readonly getDesktopWindowStatusFn: () => RuntimeDesktopWindowStatus private readonly prepareAiVaultSessionResumeFn: @@ -2942,6 +2991,12 @@ export class OrcaRuntimeService { // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. getAgentStatusSnapshot?: () => AgentStatusIpcPayload[] + /** Same rows, but including the resume-identity-only ones `getAgentStatusSnapshot` + * filters out so they can't read as running agents. Mobile native chat needs + * them: for an agent that publishes identity separately (Pi), that row is the + * only carrier of the provider session a transcript is addressed by. */ + getAgentProviderSessionSnapshot?: () => AgentStatusIpcPayload[] + getAgentProviderSessionRowsForPane?: (paneKey: string) => AgentStatusIpcPayload[] // Why: codex-home paths for the Agent Session History scan must be sourced // here, not via the window-only registerCoreHandlers path — that path never // runs under `orca serve`, so remote/SSH hosts would silently drop @@ -2953,6 +3008,7 @@ export class OrcaRuntimeService { buildAgentHookPtyEnv?: () => Record getDesktopWindowStatus?: () => RuntimeDesktopWindowStatus agentSessionClaimSigner?: AgentSessionClaimSigner + orchestrationEnvironmentTransport?: OrchestrationEnvironmentTransport } ) { this.store = store @@ -2964,11 +3020,15 @@ export class OrcaRuntimeService { this.clientSessionTabSelections.setPersistListener((state) => { this.store?.setMobileClientTabSelections?.(state) }) + this.orchestrationEnvironmentTransport = deps?.orchestrationEnvironmentTransport ?? null if (stats) { this.stats = stats this.agentDetector = new AgentDetector(stats) } this.getAgentStatusSnapshotFn = deps?.getAgentStatusSnapshot ?? null + this.getAgentProviderSessionSnapshotFn = + deps?.getAgentProviderSessionSnapshot ?? deps?.getAgentStatusSnapshot ?? null + this.getAgentProviderSessionRowsForPaneFn = deps?.getAgentProviderSessionRowsForPane ?? null // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions // even on headless `orca serve` hosts where registerCoreHandlers never runs. @@ -3421,6 +3481,136 @@ export class OrcaRuntimeService { return this.runtimeId } + resolveOrchestrationWorkerServer(selector: string): OrchestrationWorkerServer { + if (!this.orchestrationEnvironmentTransport) { + throw new OrchestrationError( + 'server_required', + 'Connected-server orchestration is unavailable in this runtime.' + ) + } + return this.orchestrationEnvironmentTransport.resolve(selector) + } + + async callOrchestrationWorkerServer( + selector: string, + method: string, + params: unknown, + timeoutMs?: number, + envelope?: RuntimeOrchestrationEnvelope + ): Promise { + if (!this.orchestrationEnvironmentTransport) { + throw new OrchestrationError( + 'server_required', + 'Connected-server orchestration is unavailable in this runtime.' + ) + } + if (isOrchestrationMutation(method, params)) { + const statusResponse = await this.orchestrationEnvironmentTransport.call( + selector, + 'status.get', + undefined, + timeoutMs + ) + if (statusResponse.ok === false) { + throw new OrchestrationError( + statusResponse.error.code, + statusResponse.error.message, + statusResponse.error.data + ) + } + const status = statusResponse.result as RuntimeStatus + if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { + throw new OrchestrationError( + 'orchestration_migration_required', + 'The connected worker server does not support the current orchestration contract. No effects were applied.', + orchestrationMigrationData('runtime_capability_missing') + ) + } + } + const response = await this.orchestrationEnvironmentTransport.call( + selector, + method, + params, + timeoutMs, + method.startsWith('orchestration.') + ? { ...envelope, orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION } + : envelope + ) + if (response.ok === false) { + throw new OrchestrationError(response.error.code, response.error.message, response.error.data) + } + return response.result + } + + async syncOrchestrationFederation(runId?: string): Promise { + if (!this.orchestrationEnvironmentTransport) { + return + } + const dispatches = this.getOrchestrationDb().listActiveFederatedDispatches(runId) + await Promise.allSettled( + dispatches.map((dispatch) => this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id)) + ) + } + + private syncOrchestrationFederatedDispatch(dispatchId: string): Promise { + const current = this.orchestrationFederationSyncs.get(dispatchId) + if (current) { + return current + } + const sync = syncFederatedDispatch(this, dispatchId) + .then(() => { + this.orchestrationFederationWarnings.delete(dispatchId) + }) + .catch((error: unknown) => { + if (!this.orchestrationFederationWarnings.has(dispatchId)) { + console.warn(`[orchestration] Federation sync failed for ${dispatchId}:`, error) + this.orchestrationFederationWarnings.add(dispatchId) + } + throw error + }) + .finally(() => { + this.orchestrationFederationSyncs.delete(dispatchId) + }) + this.orchestrationFederationSyncs.set(dispatchId, sync) + return sync + } + + ensureOrchestrationFederationRelay(runId?: string): void { + if (!this.orchestrationEnvironmentTransport) { + return + } + for (const dispatch of this.getOrchestrationDb().listActiveFederatedDispatches(runId)) { + if (this.orchestrationFederationTimers.has(dispatch.dispatch_id)) { + continue + } + const tick = () => { + const worker = this.getOrchestrationDb().getWorkerDispatch(dispatch.dispatch_id) + if (!worker || !['starting', 'ready', 'stopping'].includes(worker.state)) { + const activeTimer = this.orchestrationFederationTimers.get(dispatch.dispatch_id) + if (activeTimer) { + clearInterval(activeTimer) + } + this.orchestrationFederationTimers.delete(dispatch.dispatch_id) + this.orchestrationFederationWarnings.delete(dispatch.dispatch_id) + return + } + void this.syncOrchestrationFederatedDispatch(dispatch.dispatch_id).catch(() => undefined) + } + const timer = setInterval(tick, 1_000) + timer.unref?.() + this.orchestrationFederationTimers.set(dispatch.dispatch_id, timer) + tick() + } + } + + stopOrchestrationFederationRelay(): void { + for (const timer of this.orchestrationFederationTimers.values()) { + clearInterval(timer) + } + this.orchestrationFederationTimers.clear() + this.orchestrationFederationWarnings.clear() + } + getStartedAt(): number { return this.startedAt } @@ -3602,6 +3792,28 @@ export class OrcaRuntimeService { this.emitClientEvent({ type: 'worktreesChanged', repoId }) } + /** Detail-level worktree lifecycle tap (plugin event bus). The coarse + * worktreesChanged client event carries only repoId, which is not enough + * for subscribers that need the affected worktree's identity. + * Removal payloads carry no branch: the removal target resolves before + * the git worktree is torn down and only pins id + path. */ + onWorktreeLifecycle(listener: (event: RuntimeWorktreeLifecycleEvent) => void): () => void { + this.worktreeLifecycleListeners.add(listener) + return () => { + this.worktreeLifecycleListeners.delete(listener) + } + } + + private emitWorktreeLifecycle(event: RuntimeWorktreeLifecycleEvent): void { + for (const listener of this.worktreeLifecycleListeners) { + try { + listener(event) + } catch (err) { + console.error('[runtime] worktree lifecycle listener threw', err) + } + } + } + private notifyReposChanged(): void { this.notifier?.reposChanged() this.emitClientEvent({ type: 'reposChanged' }) @@ -10007,6 +10219,31 @@ export class OrcaRuntimeService { this.dispatchMobileNotification({ type: 'dismiss', notificationId }) } + /** Plugin panel action notifications.show. Native on desktop, relayed to + * paired mobile clients either way (mirrors notifications:dispatch). */ + async dispatchPluginNotification(input: { + pluginId: string + title: string + body?: string + }): Promise<{ delivered: boolean }> { + // Why: prefix with the plugin id so a plugin cannot spoof an Orca system + // notification or impersonate another plugin. + const title = `${input.pluginId}: ${input.title}` + const body = input.body ?? '' + let delivered = false + try { + if (Notification.isSupported()) { + new Notification({ title, body }).show() + delivered = true + } + } catch { + // Headless serve has no notification display; the mobile relay below + // still runs. + } + this.dispatchMobileNotification({ type: 'notification', source: 'plugin', title, body }) + return { delivered } + } + // ─── Account Services (mobile RPC bridge) ───────────────────── setAccountServices(services: RuntimeAccountServices): void { @@ -10835,6 +11072,7 @@ export class OrcaRuntimeService { this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.setupCompletionTokenByPtyId.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) @@ -13461,6 +13699,105 @@ export class OrcaRuntimeService { return this.getPaneKeyForTerminalHandle(handle) } + getTerminalWorktreeIdForPaneKey(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + const leaf = parsed ? this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) : null + return leaf?.worktreeId ?? this.getPtyRecordForPaneKey(paneKey)?.worktreeId ?? null + } + + /** Read-only context of the worktree the user is focused on, for plugin + * panels (workspace.readContext). Prefers the persisted session focus and + * falls back to the last-focused pane's worktree; null when neither + * resolves so panels degrade instead of erroring. */ + async resolveActiveWorktreeContext(): Promise<{ + worktreeId: string + path: string + branch: string + displayName: string + } | null> { + let worktreeId = this.store?.getWorkspaceSession?.()?.activeWorktreeId ?? null + if (!worktreeId && this.graphStatus === 'ready') { + for (const tab of this.tabs.values()) { + if (tab.activeLeafId && tab.worktreeId) { + worktreeId = tab.worktreeId + break + } + } + } + if (!worktreeId) { + return null + } + try { + const resolved = await this.resolveWorktreeSelector(`id:${worktreeId}`) + return { + worktreeId: resolved.id, + path: resolved.git.path, + branch: resolved.git.branch, + displayName: resolved.displayName + } + } catch { + return null + } + } + + getTerminalProcessIncarnation(handle: string): string | null { + const live = this.getLivePtyForHandle(handle) + const record = live?.record ?? this.handles.get(handle) + if (!record?.ptyId) { + return null + } + const incarnationId = live?.pty.incarnationId ?? this.ptysById.get(record.ptyId)?.incarnationId + if (incarnationId) { + return `${record.ptyId}:${incarnationId}` + } + // Why: legacy providers may omit process incarnation; retain the prior restart-degraded fence. + return `${this.runtimeId}:${record.ptyId}:${record.ptyGeneration}` + } + + getExactWorkerProviderSession( + handle: string, + observedAfter: number + ): ExactWorkerProviderSession | null { + const paneKey = this.getTerminalPaneKey(handle) + const processIncarnation = this.getTerminalProcessIncarnation(handle) + if (!paneKey || !processIncarnation) { + return null + } + let connectionId: string | null | undefined + let launchToken: string | null | undefined + try { + const ptyId = this.getTerminalAgentStatusPtyId(handle) + const pty = this.ptysById.get(ptyId) + connectionId = pty?.connectionId ?? null + launchToken = pty?.launchToken ?? null + } catch { + // Exact worker validation rejects this in production; test/legacy providers may not expose PTY metadata. + connectionId = undefined + launchToken = undefined + } + return selectExactWorkerProviderSession({ + paneKey, + processIncarnation, + connectionId, + launchToken, + observedAfter, + statuses: this.getAgentStatusSnapshotFn?.() ?? [] + }) + } + + validateOrchestrationAgentLauncher(agent: TuiAgent): void { + const settings = this.store?.getSettings() + if (!settings) { + throw new Error('runtime_unavailable') + } + if (!isTuiAgentEnabled(agent, settings.disabledTuiAgents)) { + throw new OrchestrationError( + 'agent_unconfigured', + `Agent launcher ${agent} is disabled or unavailable.` + ) + } + } + resolveTerminalPane(paneKey: string, expectedWorktreeId?: string): RuntimeTerminalResolvePane { // Why: the renderer context menu only knows the stable pane key; main owns // the runtime terminal handle that agents and CLI commands can address. @@ -14378,6 +14715,62 @@ export class OrcaRuntimeService { }) } + async waitForSetupTerminalCompletion(handle: string): Promise<{ exitCode: number | null }> { + const ptyId = this.getLivePtyForHandle(handle)?.pty.ptyId + if (!ptyId) { + throw new Error('terminal_handle_stale') + } + const completionToken = this.setupCompletionTokenByPtyId.get(ptyId) + const exitAbort = new AbortController() + return await new Promise<{ exitCode: number | null }>((resolve, reject) => { + let settled = false + let unsubscribe: (() => void) | null = null + const cleanup = (): void => { + unsubscribe?.() + exitAbort.abort() + } + const finish = (exitCode: number | null): void => { + if (settled) { + return + } + settled = true + cleanup() + this.setupCompletionTokenByPtyId.delete(ptyId) + resolve({ exitCode }) + } + const fail = (error: unknown): void => { + if (settled) { + return + } + settled = true + cleanup() + reject(error) + } + const scanner = completionToken ? createSetupCompletionScanner(completionToken, finish) : null + + if (scanner) { + unsubscribe = this.subscribeToTerminalData(ptyId, scanner.scan) + } + // Why: setup can finish before the observer is registered on fast local worktrees. + const replay = this.recentPtyOutputById.get(ptyId)?.read() + if (scanner && replay) { + scanner.scan(replay) + } + if (!settled) { + void this.waitForTerminal(handle, { + condition: 'exit', + signal: exitAbort.signal + }) + .then((wait) => { + if (wait.satisfied && wait.condition === 'exit' && wait.status === 'exited') { + finish(wait.exitCode) + } + }) + .catch(fail) + } + }) + } + async getWorktreePs(limit = DEFAULT_WORKTREE_PS_LIMIT): Promise<{ worktrees: RuntimeWorktreePsSummary[] totalCount: number @@ -14929,6 +15322,7 @@ export class OrcaRuntimeService { if (!this.store) { throw new Error('runtime_unavailable') } + assertProjectHostSetupHostIsSupported(args.hostId) let repo = await this.addRepo(args.path, args.kind === 'folder' ? 'folder' : 'git', args.hostId) let setup = getProjectHostSetupForRepo(this.listProjectHostSetups(), repo) if (setup.projectId !== args.projectId) { @@ -14971,6 +15365,8 @@ export class OrcaRuntimeService { } async setupProjectClone(args: ProjectHostSetupCloneArgs): Promise { + // Why: guard before cloneRepo, which would otherwise clone to the local disk. + assertProjectHostSetupHostIsSupported(args.hostId) const repo = await this.cloneRepo(args.url, args.destination, args.hostId) return await this.setupProjectExistingFolder({ projectId: args.projectId, @@ -18065,16 +18461,18 @@ export class OrcaRuntimeService { primaryTerminalHandle?: string | null hasStartupTerminal: boolean setupCommandPlatform: 'windows' | 'posix' + observeSetupCompletion?: boolean // Why: when the agent startup is sequenced to wait for setup // (waitForAgentStartup), the startup PTY runs a wrapper that already embeds // the setup command. Pass that wrapped command through so the Setup tab runs // the same script the agent is waiting on instead of a bare runner. wrappedSetupCommand?: string - }): Promise<{ setupSpawned: boolean }> { + }): Promise<{ setupSpawned: boolean; setupTerminalHandle: string | null }> { if (!this.ptyController?.spawn) { - return { setupSpawned: false } + return { setupSpawned: false, setupTerminalHandle: null } } let setupSpawned = false + let setupTerminalHandle: string | null = null try { const defaultTabHandles = await this.createDefaultTabTerminals( args.worktreeSelector, @@ -18093,25 +18491,41 @@ export class OrcaRuntimeService { primaryTerminalHandle = terminal.handle } if (args.setup) { + const completionToken = + args.observeSetupCompletion && !args.wrappedSetupCommand ? randomUUID() : null + const observedCommand = completionToken + ? buildObservedSetupCommand( + args.setup.runnerScriptPath, + args.setupCommandPlatform, + completionToken + ) + : null const setupCommand = args.wrappedSetupCommand ?? + observedCommand?.command ?? buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform) + const setupEnv = { ...args.setup.envVars, ...observedCommand?.env } const shouldSplitSetup = primaryTerminalHandle && (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') - await (shouldSplitSetup + const setupTerminal = await (shouldSplitSetup ? this.splitTerminal(primaryTerminalHandle!, { direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical', command: setupCommand, - env: args.setup.envVars, + env: setupEnv, activate: false }) : this.createTerminal(args.worktreeSelector, { title: 'Setup', command: setupCommand, - env: args.setup.envVars + env: setupEnv })) + setupTerminalHandle = setupTerminal.handle setupSpawned = true + const ptyId = this.getLivePtyForHandle(setupTerminal.handle)?.pty.ptyId + if (completionToken && ptyId) { + this.setupCompletionTokenByPtyId.set(ptyId, completionToken) + } } } catch (err) { const message = err instanceof Error ? err.message : String(err) @@ -18119,7 +18533,7 @@ export class OrcaRuntimeService { `[worktree-create] Failed to create setup/default terminals for ${args.worktreePath}: ${message}` ) } - return { setupSpawned } + return { setupSpawned, setupTerminalHandle } } private async waitForStartupFollowupReady( @@ -18253,6 +18667,8 @@ export class OrcaRuntimeService { runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' + awaitTerminalProvisioning?: boolean + observeSetupCompletion?: boolean createdWithAgent?: TuiAgent startupAgent?: TuiAgent startupPrompt?: string @@ -18350,6 +18766,12 @@ export class OrcaRuntimeService { const worktree = mergeRuntimeFolderWorkspace(repo, worktreeId, meta) this.invalidateResolvedWorktreeCache() this.notifyWorktreesChanged(repo.id) + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: worktree.id, + path: worktree.path, + branch: worktree.branch + }) const shouldActivate = args.activate === true || args.runHooks === true let warning: string | undefined let didSpawnStartup = false @@ -18439,6 +18861,12 @@ export class OrcaRuntimeService { ...(effectiveDraftPaste ? { startupDraftPaste: effectiveDraftPaste } : {}) }) const recordedLineage = this.recordCreatedWorktreeLineage(result.worktree, lineageResolution) + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: result.worktree.id, + path: result.worktree.path, + branch: result.worktree.branch + }) return { ...result, worktree: { @@ -19041,6 +19469,7 @@ export class OrcaRuntimeService { // RPC return value must omit setup so the client does not spawn it a second // time. Mirrors the wait-for-agent setup contract from #6298. let didSpawnSetup = false + let setupTerminalHandle: string | null = null let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null @@ -19131,11 +19560,13 @@ export class OrcaRuntimeService { ? 'windows' : 'posix' : 'posix', + observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // Setup tab runs the same script the sequenced agent waits on. ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle } // Why: when runtime spawned setup, omit it from activation. When setup // spawn failed, fall through with the wrapped command so renderer @@ -19171,7 +19602,7 @@ export class OrcaRuntimeService { } else if (this.ptyController?.spawn && (setup || defaultTabs || didSpawnStartup)) { // Why: inactive terminal materialization matches normal worktree creation, // but setup/default tab failures must not gate automation dispatch. - void this.provisionManagedWorktreeTerminals({ + const provisioning = this.provisionManagedWorktreeTerminals({ worktreeSelector: `id:${worktree.id}`, worktreeId: worktree.id, worktreePath, @@ -19184,12 +19615,20 @@ export class OrcaRuntimeService { ? 'windows' : 'posix' : 'posix', + observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) // Why: runtime owns setup spawning here, so the RPC result must omit setup // to keep the headless/mobile caller from launching it a second time. - if (setup) { - didSpawnSetup = true + if (args.awaitTerminalProvisioning) { + const provisioned = await provisioning + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + } else { + void provisioning + if (setup) { + didSpawnSetup = true + } } } else if (this.ptyController?.spawn) { try { @@ -19212,6 +19651,12 @@ export class OrcaRuntimeService { : {}) } : undefined + this.emitWorktreeLifecycle({ + kind: 'created', + worktreeId: worktree.id, + path: worktree.path, + branch: worktree.branch + }) return { worktree: { ...worktree, @@ -19223,6 +19668,25 @@ export class OrcaRuntimeService { }, ...(lineageInput ? { lineage, workspaceLineage, warnings: lineageWarnings } : {}), ...(returnedSetup ? { setup: returnedSetup } : {}), + ...(args.awaitTerminalProvisioning + ? { + setupReceipt: { + requested: effectiveDecision, + hookFound: Boolean(hooks?.scripts.setup), + startupPolicy: setup?.waitForAgentStartup + ? ('wait-for-setup' as const) + : ('start-immediately' as const), + state: !hooks?.scripts.setup + ? ('not_configured' as const) + : effectiveDecision === 'skip' || !shouldRunSetup + ? ('skipped' as const) + : didSpawnSetup + ? ('running' as const) + : ('spawn_failed' as const), + ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) + } + } + : {}), ...(defaultTabs ? { defaultTabs } : {}), ...(warning ? { warning } : {}), ...(addResult.localBaseRefRefresh @@ -19272,6 +19736,8 @@ export class OrcaRuntimeService { runHooks?: boolean activate?: boolean setupDecision?: 'run' | 'skip' | 'inherit' + awaitTerminalProvisioning?: boolean + observeSetupCompletion?: boolean createdWithAgent?: TuiAgent pendingFirstAgentMessageRename?: boolean automationProvenance?: AutomationWorkspaceProvenance @@ -19349,6 +19815,7 @@ export class OrcaRuntimeService { // Why: same no-double-spawn contract as the local path — once runtime // provisions setup, omit it from activation and the RPC result. let didSpawnSetup = false + let setupTerminalHandle: string | null = null let startupTerminalHandle: string | null = null let startupTerminalTabId: string | null = null let startupTerminalPaneKey: string | null = null @@ -19432,11 +19899,13 @@ export class OrcaRuntimeService { ? 'windows' : 'posix' : 'posix', + observeSetupCompletion: args.observeSetupCompletion, // Why: carry the wait-for-agent wrapped setup command (#6298) so the // remote Setup tab runs the same script the sequenced agent waits on. ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle } // Why: omit setup from activation when runtime spawned it; on spawn // failure fall through with the wrapped command so renderer retries. @@ -19477,7 +19946,7 @@ export class OrcaRuntimeService { ) { // Why: inactive terminal materialization matches normal worktree creation, // but setup/default tab failures must not gate automation dispatch. - void this.provisionManagedWorktreeTerminals({ + const provisioning = this.provisionManagedWorktreeTerminals({ worktreeSelector: `path:${result.worktree.path}`, worktreeId: result.worktree.id, worktreePath: result.worktree.path, @@ -19490,12 +19959,20 @@ export class OrcaRuntimeService { ? 'windows' : 'posix' : 'posix', + observeSetupCompletion: args.observeSetupCompletion, ...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}) }) // Why: runtime owns setup spawning here, so omit setup from the RPC result // to keep the headless/mobile caller from launching it a second time. - if (result.setup) { - didSpawnSetup = true + if (args.awaitTerminalProvisioning) { + const provisioned = await provisioning + didSpawnSetup = provisioned.setupSpawned + setupTerminalHandle = provisioned.setupTerminalHandle + } else { + void provisioning + if (result.setup) { + didSpawnSetup = true + } } } else if (!shouldActivate && this.ptyController?.spawn) { try { @@ -19540,7 +20017,27 @@ export class OrcaRuntimeService { } : resultForRenderer - return warning ? { ...resultWithStartupTerminal, warning } : resultWithStartupTerminal + const requestedSetupDecision = args.runHooks ? 'run' : (args.setupDecision ?? 'inherit') + const setupReceipt = { + requested: requestedSetupDecision, + hookFound: Boolean(result.setup), + startupPolicy: result.setup?.waitForAgentStartup + ? ('wait-for-setup' as const) + : ('start-immediately' as const), + state: + requestedSetupDecision === 'skip' + ? ('skipped' as const) + : !result.setup + ? ('not_configured' as const) + : didSpawnSetup + ? ('running' as const) + : ('spawn_failed' as const), + ...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {}) + } + const resultWithSetupReceipt = args.awaitTerminalProvisioning + ? { ...resultWithStartupTerminal, setupReceipt } + : resultWithStartupTerminal + return warning ? { ...resultWithSetupReceipt, warning } : resultWithSetupReceipt } /** @@ -21146,7 +21643,13 @@ export class OrcaRuntimeService { })() this.removeManagedWorktreeInFlight.set(removalTarget.id, { optionsKey, promise: removal }) try { - return await removal + const result = await removal + this.emitWorktreeLifecycle({ + kind: 'removed', + worktreeId: removalTarget.id, + path: removalTarget.path + }) + return result } finally { if (this.removeManagedWorktreeInFlight.get(removalTarget.id)?.promise === removal) { this.removeManagedWorktreeInFlight.delete(removalTarget.id) @@ -25467,6 +25970,7 @@ export class OrcaRuntimeService { this.advancePtyLifecycleGeneration(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.setupCompletionTokenByPtyId.delete(ptyId) this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) @@ -26083,6 +26587,27 @@ export class OrcaRuntimeService { ): RuntimeMobileSessionTabsResult { const tabs: RuntimeMobileSessionClientTab[] = [] const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree) + // Production reads hook rows by pane; the snapshot fallback remains for tests + // and embedders that have not adopted the narrow getter. + let hookRowsByPaneKey: Map | null = null + const hookRowsForPane = new Map() + const getHookRowsForPane = (paneKey: string): AgentStatusIpcPayload[] => { + const cached = hookRowsForPane.get(paneKey) + if (cached) { + return cached + } + const direct = this.getAgentProviderSessionRowsForPaneFn?.(paneKey) + if (direct) { + hookRowsForPane.set(paneKey, direct) + return direct + } + hookRowsByPaneKey ??= indexAgentStatusRowsByPaneKey( + this.getAgentProviderSessionSnapshotFn?.() ?? [] + ) + const rows = hookRowsByPaneKey.get(paneKey) ?? [] + hookRowsForPane.set(paneKey, rows) + return rows + } // Why: a live PTY backs one surface; claim each once so two leaves resolving to it can't emit duplicate React keys and crash the client. const claimedLivePtyIds = new Set() for (const tab of snapshot.tabs) { @@ -26221,7 +26746,8 @@ export class OrcaRuntimeService { mobileStatusPty, tab, terminalHandle, - retainedAgentStatus + retainedAgentStatus, + getHookRowsForPane )), ...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}), ...(tab.startupCwd ? { startupCwd: tab.startupCwd } : {}), @@ -26278,12 +26804,25 @@ export class OrcaRuntimeService { pty: RuntimePtyWorktreeRecord | null, tab: RuntimeMobileSessionTerminalTab, terminalHandle: string | null, - retained: RuntimeAgentRowSnapshot | null + retained: RuntimeAgentRowSnapshot | null, + getHookRowsForPane: (paneKey: string) => AgentStatusIpcPayload[] ): { agentStatus: AgentStatusEntry } | Record { const paneKey = this.getMobileTerminalPaneKey(tab) - if (!pty?.lastAgentStatus && !retained) { + // Why: neither the OSC-retained row nor a title-derived status can carry a + // provider session — only the hook payload does, and headless serve has no + // renderer to publish `tab.agentStatus`. Without it mobile native chat has no + // transcript to address and sits on the empty state forever. + const hookRow = this.getHookAgentRowForPane(getHookRowsForPane(paneKey)) + // Why: the hook row is evidence in its own right. Returning early on a missing + // PTY status/retained row put this check ahead of the only headless carrier, so + // an agent that reported its session but never emitted a recognized title got no + // `agentStatus` at all — exactly the hook-only case the fallback exists for. + if (!pty?.lastAgentStatus && !retained && !hookRow.agentType && !hookRow.providerSession) { return {} } + const providerSession = hookRow.providerSession + ? { providerSession: hookRow.providerSession } + : {} const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null const ptyTitle = pty ? getLatestAgentCandidateTitle( @@ -26300,7 +26839,14 @@ export class OrcaRuntimeService { if (ptyTitle !== null && ptyTitleClassification !== 'agent') { // Why: non-agent title = shell reclaimed the pane; suppress to clear stuck spinners (#1437), though a live hook signal survives. const hasLiveHookSignal = - retained?.payload.interactivePrompt != null || retained?.payload.toolName != null + retained?.payload.interactivePrompt != null || + retained?.payload.toolName != null || + // Why: headless serve has no renderer to retain an OSC row, so a fresh hook + // agentType is the only live signal a hook-only pane can offer — and an agent + // that reports over HTTP need never set a title this gate would recognize. + // Scoped to panes with no PTY status at all, so it cannot revive a spinner: + // this branch publishes `done`. It only keeps the transcript addressable. + (!pty?.lastAgentStatus && (hookRow.agentType != null || hookRow.providerSession != null)) if (!hasLiveHookSignal) { return {} } @@ -26309,7 +26855,7 @@ export class OrcaRuntimeService { const ownerAgent = resolvePaneAgentOwner({ launchAgent: tab.launchAgent ?? pty?.launchAgent ?? null, - hookAgent: retained?.payload.agentType ?? null + hookAgent: retained?.payload.agentType ?? hookRow.agentType }) ?? pty?.foregroundAgent ?? null @@ -26332,20 +26878,23 @@ export class OrcaRuntimeService { ? { worktreeId: pty?.worktreeId ?? retained.worktreeId } : {}), tabId: tab.parentTabId, - terminalTitle + terminalTitle, + ...providerSession }, ownerAgent ) } } - const now = pty!.lastOutputAt ?? Date.now() + // A hook-only pane has no PTY status to date the row from; `done` with a + // now-stamp is the honest projection — the hook proves identity, not liveness. + const now = pty?.lastOutputAt ?? Date.now() const agentType = ownerAgent ?? undefined return { agentStatus: { state: - pty!.lastAgentStatus === 'working' + pty?.lastAgentStatus === 'working' ? 'working' - : pty!.lastAgentStatus === 'permission' + : pty?.lastAgentStatus === 'permission' ? 'blocked' : 'done', prompt: '', @@ -26354,14 +26903,57 @@ export class OrcaRuntimeService { paneKey, ...(terminalHandle ? { terminalHandle } : {}), ...(agentType ? { agentType } : {}), - worktreeId: pty!.worktreeId, + ...(pty?.worktreeId ? { worktreeId: pty.worktreeId } : {}), tabId: tab.parentTabId, terminalTitle, - stateHistory: [] + stateHistory: [], + ...providerSession } } } + /** Hook-reported identity for this pane, newest wins per field. + * + * `providerSession` is deliberately unbounded: it is resume identity, not live + * state, it stays correct until the pane relaunches (which overwrites the row + * under the same paneKey), and it is only ever read once an agent is already + * established. Bounding it would blank mobile native chat on an idle session. + * + * `agentType` is bounded by the same staleness window the retained OSC path uses, + * because it is the signal that claims an agent owns the pane at all. A user who + * exits the agent leaves `pty.lastAgentStatus` behind forever, so an unbounded + * read would keep offering native chat for what is now a plain shell. */ + private getHookAgentRowForPane(rows: readonly AgentStatusIpcPayload[]): { + providerSession: AgentProviderSessionMetadata | null + agentType: string | null + } { + let session: AgentStatusIpcPayload | null = null + let agent: AgentStatusIpcPayload | null = null + const agentTypeFreshAfter = Date.now() - AGENT_STATUS_STALE_AFTER_MS + // Why pane key only: the sibling `terminalHandle` arm this used to carry never + // matched. `toAgentStatusIpcPayload` does not emit the field on the hook path + // (only the renderer's own store stamps it, and headless serve has no renderer), + // and because it is optional TypeScript could not flag the dead comparison. + for (const entry of rows) { + if (entry.providerSession && (!session || entry.receivedAt > session.receivedAt)) { + session = entry + } + if ( + entry.agentType && + (entry.providerSessionOnly !== true || + (entry.agentType === 'pi' && entry.providerSession != null)) && + entry.receivedAt >= agentTypeFreshAfter && + (!agent || entry.receivedAt > agent.receivedAt) + ) { + agent = entry + } + } + return { + providerSession: session?.providerSession ?? null, + agentType: agent?.agentType ?? null + } + } + /** Retained OSC 9999 hook row for this mobile tab if still fresh; looked up by pane identity, then PTY ownership (legacy `pane:N` ids can drift). */ private getFreshRetainedAgentStatusForMobileTab( paneKey: string, @@ -26838,15 +27430,25 @@ export class OrcaRuntimeService { if (messageType && waiter.typeFilter && !waiter.typeFilter.includes(messageType)) { continue } - this.resolveMessageWaiter(waiter) + this.resolveMessageWaiter(waiter, 'notified') } } waitForMessage( handle: string, - options?: { typeFilter?: string[]; timeoutMs?: number; signal?: AbortSignal } - ): Promise { + options?: { + typeFilter?: string[] + timeoutMs?: number + signal?: AbortSignal + exclusive?: boolean + } + ): Promise { return new Promise((resolve) => { + const currentWaiters = this.messageWaitersByHandle.get(handle) + if (options?.exclusive && currentWaiters && currentWaiters.size > 0) { + resolve('waiter_exists') + return + } const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS const waiter: MessageWaiter = { @@ -26861,11 +27463,11 @@ export class OrcaRuntimeService { const signal = options?.signal const onAbort = (): void => { this.removeMessageWaiter(waiter) - resolve() + resolve('cancelled') } if (signal) { if (signal.aborted) { - resolve() + resolve('cancelled') return } waiter.abortCleanup = () => signal.removeEventListener('abort', onAbort) @@ -26874,7 +27476,7 @@ export class OrcaRuntimeService { waiter.timeout = setTimeout(() => { this.removeMessageWaiter(waiter) - resolve() + resolve('timed_out') }, timeoutMs) let waiters = this.messageWaitersByHandle.get(handle) @@ -26886,9 +27488,19 @@ export class OrcaRuntimeService { }) } - private resolveMessageWaiter(waiter: MessageWaiter): void { + cancelMessageWaiters(handle: string): void { + const waiters = this.messageWaitersByHandle.get(handle) + if (!waiters) { + return + } + for (const waiter of [...waiters]) { + this.resolveMessageWaiter(waiter, 'cancelled') + } + } + + private resolveMessageWaiter(waiter: MessageWaiter, result: MessageWaitResult): void { this.removeMessageWaiter(waiter) - waiter.resolve() + waiter.resolve(result) } private removeMessageWaiter(waiter: MessageWaiter): void { diff --git a/src/main/runtime/orchestration-cli-subprocess.test.ts b/src/main/runtime/orchestration-cli-subprocess.test.ts index 6ec938a7e28..778ee9d8545 100644 --- a/src/main/runtime/orchestration-cli-subprocess.test.ts +++ b/src/main/runtime/orchestration-cli-subprocess.test.ts @@ -19,7 +19,7 @@ import { spawn } from 'node:child_process' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { OrcaRuntimeService } from './orca-runtime' import { OrchestrationDb } from './orchestration/db' import { OrcaRuntimeRpcServer } from './runtime-rpc' @@ -198,6 +198,15 @@ describeIfBuilt('orca orchestration reset subprocess', () => { const runtime = new OrcaRuntimeService() const db = new OrchestrationDb(':memory:') runtime.setOrchestrationDb(db) + const coordinatorPaneKey = 'tab_cli:11111111-1111-4111-8111-111111111111' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_cli' ? coordinatorPaneKey : null + ) + db.createRun({ + objective: 'CLI reset subprocess fixture', + coordinatorHandle: 'term_cli', + coordinatorPaneKey + }) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath }) await server.start() @@ -218,6 +227,8 @@ describeIfBuilt('orca orchestration reset subprocess', () => { 'task-create', '--spec', 'throwaway task', + '--from', + 'term_cli', '--json' ]) expect(create.exitCode, create.stderr).toBe(0) @@ -253,18 +264,41 @@ describeIfBuilt('orca orchestration reset subprocess', () => { expect(db.getInbox()).toHaveLength(1) expect(db.listTasks()).toHaveLength(0) + // Why: task resets intentionally remove Runs, so recreate the caller's + // normal binding before exercising task creation again. + db.createRun({ + objective: 'CLI reset subprocess fixture after reset', + coordinatorHandle: 'term_cli', + coordinatorPaneKey + }) const recreate = await runBuiltCli(userDataPath, [ 'orchestration', 'task-create', '--spec', 'throwaway task after partial reset', + '--from', + 'term_cli', '--json' ]) expect(recreate.exitCode, recreate.stderr).toBe(0) expect(db.getInbox()).toHaveLength(1) expect(db.listTasks()).toHaveLength(1) - const resetAll = await runBuiltCli(userDataPath, ['orchestration', 'reset', '--json']) + const bareReset = await runBuiltCli(userDataPath, ['orchestration', 'reset', '--json']) + expect(bareReset.exitCode).toBe(1) + expect(JSON.parse(bareReset.stdout)).toMatchObject({ + ok: false, + error: { code: 'invalid_argument' } + }) + expect(db.getInbox()).toHaveLength(1) + expect(db.listTasks()).toHaveLength(1) + + const resetAll = await runBuiltCli(userDataPath, [ + 'orchestration', + 'reset', + '--all', + '--json' + ]) expect(resetAll.exitCode, resetAll.stderr).toBe(0) expect(JSON.parse(resetAll.stdout)).toMatchObject({ ok: true, result: { reset: 'all' } }) expect(db.getInbox()).toHaveLength(0) diff --git a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap index c55426c4216..39593677687 100644 --- a/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap +++ b/src/main/runtime/orchestration/__snapshots__/preamble.test.ts.snap @@ -10,7 +10,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. === CLI COMMANDS === - # Report task completion (REQUIRED when done — even on failure). + # Report the terminal task outcome (REQUIRED exactly once). # # RULE: --body must be a 3-sentence executive summary (what you did, # what you found, what's left). Never send an empty body; the coordinator @@ -18,14 +18,15 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # If you produced a long-form artifact, include its path as # payload.reportPath so the coordinator can find it without a file search. # - # RULE: send worker_done exactly once. Failure is still a worker_done - # with subject like "Failed: " — never silently exit. + # RULE: send worker_done exactly once. Use --outcome succeeded when the + # requested work is done, or replace it with --outcome failed when it is not. + # Never encode failure only in prose and never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - orca orchestration send --to term_COORD --from term_WORKER \\ + orca orchestration send --from term_WORKER \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ - --task-id task_SNAP --dispatch-id ctx_SNAP \\ + --task-id task_SNAP --dispatch-id ctx_SNAP --outcome succeeded \\ --files-modified "path/a,path/b" \\ --report-path "" @@ -39,7 +40,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - orca orchestration send --to term_COORD --from term_WORKER \\ + orca orchestration send --from term_WORKER \\ --type heartbeat --subject "alive" \\ --task-id task_SNAP --dispatch-id ctx_SNAP \\ --phase "" @@ -52,18 +53,18 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # coordinator cannot see and cannot answer — your session will hang forever # waiting on a human. Every interactive question goes through \`ask\` below. # - # The \`ask\` verb is a thin wrapper: it sends a decision_gate message and - # blocks on \`check --wait\` until the coordinator replies, then prints the - # reply body. Use it anywhere you would otherwise have reached for - # AskUserQuestion. - orca orchestration ask --to term_COORD --from term_WORKER \\ + # The \`ask\` verb durably records a question in this Dispatch's Run and + # blocks until the coordinator replies, then prints the reply body. If the + # call times out or disconnects, resume with the returned message ID instead + # of creating a duplicate question. + orca orchestration ask --from term_WORKER \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - orca orchestration send --to term_COORD --from term_WORKER \\ + orca orchestration send --from term_WORKER \\ --type escalation --subject "Blocked: " \\ --body "
    " \\ --task-id task_SNAP diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts index cb532b71e36..31e9180c93e 100644 --- a/src/main/runtime/orchestration/coordinator.test.ts +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -98,6 +98,7 @@ function insertWorkerDone( payload: JSON.stringify({ taskId: params.taskId, dispatchId, + outcome: 'succeeded', ...(params.filesModified ? { filesModified: params.filesModified } : {}) }), senderPaneKey: @@ -192,7 +193,7 @@ describe('Coordinator', () => { to: 'coord', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) }) reconcileLifecycleMessage(db, msg) @@ -214,7 +215,11 @@ describe('Coordinator', () => { const task = db.createTask({ spec: 'duplicate completion' }) const dispatch = db.createDispatchContext(task.id, 'term_a') - const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + const payload = JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) const first = db.insertMessage({ from: 'term_a', to: 'coord', @@ -569,7 +574,11 @@ describe('Coordinator', () => { to: 'coord', subject: 'Late done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: staleCtx.id }) + payload: JSON.stringify({ + taskId: task.id, + dispatchId: staleCtx.id, + outcome: 'succeeded' + }) }) const staleCoordinator = new Coordinator(db, runtime, { @@ -621,7 +630,7 @@ describe('Coordinator', () => { to: 'coord', subject: 'Done after restart', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id }), + payload: JSON.stringify({ taskId: task.id, dispatchId: ctx.id, outcome: 'succeeded' }), senderPaneKey: `tab_after:${leafId}` }) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts index d0e56b7e40a..2af865a0df5 100644 --- a/src/main/runtime/orchestration/coordinator.ts +++ b/src/main/runtime/orchestration/coordinator.ts @@ -242,6 +242,7 @@ export class Coordinator { case 'dispatch': case 'handoff': case 'merge_ready': + case 'question': break } } @@ -255,6 +256,10 @@ export class Coordinator { if (!this.state.completedTasks.includes(result.taskId)) { this.state.completedTasks.push(result.taskId) } + return + } + if (result.action === 'failed' && !this.state.failedTasks.includes(result.taskId)) { + this.state.failedTasks.push(result.taskId) } } diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index 15fb0a06a00..73e0468d011 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import Database from '../../sqlite/sync-database' -import { OrchestrationDb } from './db' +import { LEGACY_RUN_ID, OrchestrationDb } from './db' import type { MessageType } from './db' // Overwrites the datetime('now')-seeded timestamps with explicit fixture values @@ -942,6 +942,8 @@ describe('OrchestrationDb', () => { // v1 data preserved expect(d.getMessageById('msg_v1')?.subject).toBe('pre-migration') + expect(d.getMessageById('msg_v1')?.run_id).toBe(LEGACY_RUN_ID) + expect(d.getRun(LEGACY_RUN_ID)).toMatchObject({ legacy: 1 }) }) it('adds pane-identity columns (v6) and persists them', () => { diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index b287cb0182c..04fcb29fb55 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -1,5 +1,6 @@ /* eslint-disable max-lines -- Why: the orchestration DB keeps schema creation, message CRUD, task DAG resolution, and dispatch context management in one class so transactional invariants (e.g. promoteReadyTasks running inside the same writer as updateTaskStatus) are enforced by locality. */ -import { randomBytes } from 'node:crypto' +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { chmodSync, existsSync } from 'node:fs' import Database from '../../sqlite/sync-database' import type { MessageType, @@ -12,10 +13,26 @@ import type { TaskRow, DispatchContextRow, DecisionGateRow, - CoordinatorRun + CoordinatorRun, + WorkerReportOutcome, + WorkerReportSettlement, + RunRow, + DeliveryRow, + DeliveryStatus, + QuestionRow, + QuestionStatus, + MutationReceiptRow, + MutationState, + WorkerDispatchRow, + WorkerDispatchState, + FederatedDispatchRow, + RemoteDispatchAttachmentRow, + FederationRelayDirection, + FederationRelayItemRow } from './types' import { buildOrchestrationTaskDisplayMetadata } from '../../../shared/orchestration-task-display' import { parsePaneKey } from '../../../shared/stable-pane-id' +import { OrchestrationError } from './orchestration-error' // Why: leaf UUID is the remint-stable pane identity (tab half changes on break-out); exact match covers legacy/unparseable keys. function isEquivalentPaneKey(a: string, b: string): boolean { @@ -38,14 +55,29 @@ export type { TaskRow, DispatchContextRow, DecisionGateRow, - CoordinatorRun + CoordinatorRun, + WorkerReportOutcome, + WorkerReportSettlement, + RunRow, + DeliveryRow, + DeliveryStatus, + QuestionRow, + QuestionStatus, + MutationReceiptRow, + MutationState, + WorkerDispatchRow, + WorkerDispatchState } function generateId(prefix: string): string { return `${prefix}_${randomBytes(6).toString('hex')}` } -function addLifecycleRejectionMarker(payload: string | null, reason: string): string { +function hashDispatchCapability(capability: string): string { + return createHash('sha256').update(capability).digest('hex') +} + +function addLifecycleRejectionMarker(payload: string | null, code: string, reason: string): string { let parsed: Record = {} try { const value: unknown = payload ? JSON.parse(payload) : {} @@ -57,7 +89,7 @@ function addLifecycleRejectionMarker(payload: string | null, reason: string): st } return JSON.stringify({ ...parsed, - _orcaLifecycleRejection: { code: 'sender_not_assignee', reason } + _orcaLifecycleRejection: { code, reason } }) } @@ -83,8 +115,47 @@ function exposeMessageListTimestamps(messages: MessageRow[]): MessageRow[] { return messages.map(exposeMessageTimestamps) } -// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane-identity columns. -const SCHEMA_VERSION = 6 +function exposeRunTimestamps(run: RunRow): RunRow { + return { + ...run, + created_at: exposeUtcTimestamp(run.created_at) ?? run.created_at, + updated_at: exposeUtcTimestamp(run.updated_at) ?? run.updated_at + } +} + +function exposeDeliveryTimestamps(delivery: DeliveryRow): DeliveryRow { + return { + ...delivery, + created_at: exposeUtcTimestamp(delivery.created_at) ?? delivery.created_at, + acknowledged_at: exposeUtcTimestamp(delivery.acknowledged_at) + } +} + +function exposeQuestionTimestamps(question: QuestionRow): QuestionRow { + return { + ...question, + created_at: exposeUtcTimestamp(question.created_at) ?? question.created_at, + answered_at: exposeUtcTimestamp(question.answered_at), + closed_at: exposeUtcTimestamp(question.closed_at) + } +} + +export const LEGACY_RUN_ID = 'run_legacy_local' + +// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state. +const SCHEMA_VERSION = 17 + +function hardenOrchestrationDatabaseFiles(dbPath: string | ':memory:'): void { + if (dbPath === ':memory:' || process.platform === 'win32') { + // Why: Windows protects these files through Orca's current-user-only userData DACL; POSIX mode bits are inert there. + return + } + for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + if (existsSync(path)) { + chmodSync(path, 0o600) + } + } +} export class OrchestrationDb { private db: Database.Database @@ -104,12 +175,26 @@ export class OrchestrationDb { this.db.pragma('busy_timeout = 5000') this.createTables() this.migrate() + hardenOrchestrationDatabaseFiles(dbPath) } private createTables(): void { this.db.exec(` + CREATE TABLE IF NOT EXISTS runs ( + id TEXT PRIMARY KEY, + objective TEXT NOT NULL, + home_database TEXT NOT NULL DEFAULT 'this_database', + coordinator_handle TEXT, + coordinator_pane_key TEXT, + consumer_generation INTEGER NOT NULL DEFAULT 0, + legacy INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS messages ( id TEXT NOT NULL, + run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}', from_handle TEXT NOT NULL, to_handle TEXT NOT NULL, subject TEXT NOT NULL, @@ -117,7 +202,7 @@ export class OrchestrationDb { type TEXT NOT NULL DEFAULT 'status' CHECK(type IN ( 'status', 'dispatch', 'worker_done', 'merge_ready', - 'escalation', 'handoff', 'decision_gate', 'heartbeat' + 'escalation', 'handoff', 'decision_gate', 'question', 'heartbeat' )), priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('normal', 'high', 'urgent')), @@ -134,8 +219,129 @@ export class OrchestrationDb { CREATE INDEX IF NOT EXISTS idx_inbox ON messages(to_handle, read); CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id); + CREATE TABLE IF NOT EXISTS deliveries ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + consumer_generation INTEGER NOT NULL, + message_ids TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'outstanding' + CHECK(status IN ('outstanding', 'acknowledged', 'fenced')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + acknowledged_at TEXT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_deliveries_one_outstanding + ON deliveries(run_id) WHERE status = 'outstanding'; + CREATE INDEX IF NOT EXISTS idx_deliveries_run_created + ON deliveries(run_id, created_at); + + CREATE TABLE IF NOT EXISTS mutation_receipts ( + caller_fingerprint TEXT NOT NULL, + request_id TEXT NOT NULL, + method TEXT NOT NULL, + payload_hash TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK(state IN ('pending', 'completed')), + receipt TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (caller_fingerprint, request_id) + ); + + CREATE TABLE IF NOT EXISTS worker_dispatches ( + dispatch_id TEXT PRIMARY KEY, + runtime_epoch TEXT, + state TEXT NOT NULL DEFAULT 'starting' + CHECK(state IN ( + 'starting', 'ready', 'start_unknown', 'failed', 'succeeded', + 'stopping', 'stop_unknown', 'stopped', 'abandoned' + )), + stage TEXT NOT NULL DEFAULT 'accepted', + worktree_id TEXT, + agent_terminal_handle TEXT, + setup_state TEXT NOT NULL DEFAULT 'not_applicable', + effects TEXT NOT NULL DEFAULT '[]', + residual_resources TEXT NOT NULL DEFAULT '[]', + start_options TEXT NOT NULL DEFAULT '{}', + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS federated_dispatches ( + dispatch_id TEXT PRIMARY KEY, + environment_id TEXT NOT NULL, + environment_name TEXT NOT NULL, + peer_fingerprint TEXT NOT NULL, + remote_runtime_epoch TEXT, + protocol_version INTEGER NOT NULL DEFAULT 1, + remote_worktree_id TEXT, + remote_terminal_handle TEXT, + to_home_imported_sequence INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS remote_dispatch_attachments ( + dispatch_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + home_peer_fingerprint TEXT NOT NULL, + protocol_version INTEGER NOT NULL DEFAULT 1, + runtime_epoch TEXT NOT NULL, + capability_hash TEXT, + pane_key TEXT, + process_incarnation TEXT, + state TEXT NOT NULL DEFAULT 'starting' + CHECK(state IN ( + 'starting', 'ready', 'start_unknown', 'failed', 'succeeded', + 'stopping', 'stop_unknown', 'stopped', 'abandoned' + )), + stage TEXT NOT NULL DEFAULT 'accepted', + worktree_id TEXT, + terminal_handle TEXT, + setup_state TEXT NOT NULL DEFAULT 'not_applicable', + effects TEXT NOT NULL DEFAULT '[]', + residual_resources TEXT NOT NULL DEFAULT '[]', + to_worker_imported_sequence INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS federation_relay_items ( + dispatch_id TEXT NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('to_home', 'to_worker')), + sequence INTEGER NOT NULL, + message_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + byte_count INTEGER NOT NULL, + acked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (dispatch_id, direction, sequence), + UNIQUE (dispatch_id, direction, message_id) + ); + + CREATE INDEX IF NOT EXISTS idx_federation_relay_pending + ON federation_relay_items(dispatch_id, direction, acked_at, sequence); + + CREATE TABLE IF NOT EXISTS remote_questions ( + message_id TEXT PRIMARY KEY, + dispatch_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'answered', 'closed')), + answer_message_id TEXT, + answer_body TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + answered_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_remote_questions_dispatch_status + ON remote_questions(dispatch_id, status); + CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, + run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}', parent_id TEXT, created_by_terminal_handle TEXT, task_title TEXT, @@ -157,9 +363,13 @@ export class OrchestrationDb { CREATE TABLE IF NOT EXISTS dispatch_contexts ( id TEXT PRIMARY KEY, + run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}', task_id TEXT NOT NULL, assignee_handle TEXT, assignee_pane_key TEXT, + capability_hash TEXT, + process_incarnation TEXT, + capability_revoked_at TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), failure_count INTEGER NOT NULL DEFAULT 0, @@ -175,6 +385,7 @@ export class OrchestrationDb { CREATE TABLE IF NOT EXISTS decision_gates ( id TEXT PRIMARY KEY, + run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}', task_id TEXT NOT NULL, question TEXT NOT NULL, options TEXT NOT NULL DEFAULT '[]', @@ -229,7 +440,7 @@ export class OrchestrationDb { type TEXT NOT NULL DEFAULT 'status' CHECK(type IN ( 'status', 'dispatch', 'worker_done', 'merge_ready', - 'escalation', 'handoff', 'decision_gate', 'heartbeat' + 'escalation', 'handoff', 'decision_gate', 'question', 'heartbeat' )), priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('normal', 'high', 'urgent')), @@ -287,6 +498,253 @@ export class OrchestrationDb { this.db.exec(`ALTER TABLE messages ADD COLUMN sender_pane_key TEXT`) } } + if (current < 7) { + this.db + .prepare( + `INSERT OR IGNORE INTO runs ( + id, objective, home_database, consumer_generation, legacy + ) VALUES (?, ?, 'this_database', 0, 1)` + ) + .run(LEGACY_RUN_ID, 'Legacy orchestration state (inspect only)') + for (const table of ['messages', 'tasks', 'dispatch_contexts', 'decision_gates']) { + if (!this.hasColumn(table, 'run_id')) { + this.db.exec( + `ALTER TABLE ${table} ADD COLUMN run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}'` + ) + } + } + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_messages_run_sequence ON messages(run_id, sequence); + CREATE INDEX IF NOT EXISTS idx_tasks_run_status ON tasks(run_id, status); + CREATE INDEX IF NOT EXISTS idx_dispatch_run_status ON dispatch_contexts(run_id, status); + CREATE INDEX IF NOT EXISTS idx_gates_run_status ON decision_gates(run_id, status); + CREATE INDEX IF NOT EXISTS idx_runs_coordinator_pane ON runs(coordinator_pane_key); + `) + } + if (current < 8) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS deliveries ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + consumer_generation INTEGER NOT NULL, + message_ids TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'outstanding' + CHECK(status IN ('outstanding', 'acknowledged', 'fenced')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + acknowledged_at TEXT + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_deliveries_one_outstanding + ON deliveries(run_id) WHERE status = 'outstanding'; + CREATE INDEX IF NOT EXISTS idx_deliveries_run_created + ON deliveries(run_id, created_at); + + CREATE TABLE IF NOT EXISTS question_threads ( + message_id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + dispatch_id TEXT NOT NULL, + asker_handle TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'answered', 'closed')), + answer_message_id TEXT, + answer_body TEXT, + answered_by_generation INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + answered_at TEXT, + closed_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_questions_dispatch_status + ON question_threads(dispatch_id, status); + `) + } + if (current < 9 && !this.messagesTypeCheckAllowsQuestion()) { + this.db.exec(` + CREATE TABLE messages_new ( + id TEXT NOT NULL, + run_id TEXT NOT NULL DEFAULT '${LEGACY_RUN_ID}', + from_handle TEXT NOT NULL, + to_handle TEXT NOT NULL, + subject TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + type TEXT NOT NULL DEFAULT 'status' + CHECK(type IN ( + 'status', 'dispatch', 'worker_done', 'merge_ready', + 'escalation', 'handoff', 'decision_gate', 'question', 'heartbeat' + )), + priority TEXT NOT NULL DEFAULT 'normal' + CHECK(priority IN ('normal', 'high', 'urgent')), + thread_id TEXT, + payload TEXT, + read INTEGER NOT NULL DEFAULT 0, + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + delivered_at TEXT, + sender_pane_key TEXT + ); + INSERT INTO messages_new ( + id, run_id, from_handle, to_handle, subject, body, type, priority, + thread_id, payload, read, sequence, created_at, delivered_at, sender_pane_key + ) + SELECT + id, run_id, from_handle, to_handle, subject, body, type, priority, + thread_id, payload, read, sequence, created_at, delivered_at, sender_pane_key + FROM messages; + DROP TABLE messages; + ALTER TABLE messages_new RENAME TO messages; + + CREATE UNIQUE INDEX idx_messages_id ON messages(id); + CREATE INDEX idx_inbox ON messages(to_handle, read); + CREATE INDEX idx_thread ON messages(thread_id); + CREATE INDEX idx_messages_run_sequence ON messages(run_id, sequence); + CREATE INDEX idx_messages_undelivered_inbox + ON messages(to_handle, read, delivered_at, sequence); + `) + } + if (current < 10) { + if (!this.hasColumn('dispatch_contexts', 'capability_hash')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN capability_hash TEXT') + } + if (!this.hasColumn('dispatch_contexts', 'process_incarnation')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN process_incarnation TEXT') + } + if (!this.hasColumn('dispatch_contexts', 'capability_revoked_at')) { + this.db.exec('ALTER TABLE dispatch_contexts ADD COLUMN capability_revoked_at TEXT') + } + } + if (current < 11) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS mutation_receipts ( + caller_fingerprint TEXT NOT NULL, + request_id TEXT NOT NULL, + method TEXT NOT NULL, + payload_hash TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK(state IN ('pending', 'completed')), + receipt TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (caller_fingerprint, request_id) + ); + `) + } + if (current < 12) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS worker_dispatches ( + dispatch_id TEXT PRIMARY KEY, + runtime_epoch TEXT, + state TEXT NOT NULL DEFAULT 'starting' + CHECK(state IN ( + 'starting', 'ready', 'start_unknown', 'failed', 'succeeded', + 'stopping', 'stop_unknown', 'stopped', 'abandoned' + )), + stage TEXT NOT NULL DEFAULT 'accepted', + worktree_id TEXT, + agent_terminal_handle TEXT, + setup_state TEXT NOT NULL DEFAULT 'not_applicable', + effects TEXT NOT NULL DEFAULT '[]', + residual_resources TEXT NOT NULL DEFAULT '[]', + start_options TEXT NOT NULL DEFAULT '{}', + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `) + } + if (current < 13 && !this.hasColumn('worker_dispatches', 'runtime_epoch')) { + this.db.exec('ALTER TABLE worker_dispatches ADD COLUMN runtime_epoch TEXT') + } + if (current < 14) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS federated_dispatches ( + dispatch_id TEXT PRIMARY KEY, + environment_id TEXT NOT NULL, + environment_name TEXT NOT NULL, + peer_fingerprint TEXT NOT NULL, + remote_runtime_epoch TEXT, + protocol_version INTEGER NOT NULL DEFAULT 1, + remote_worktree_id TEXT, + remote_terminal_handle TEXT, + to_home_imported_sequence INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS remote_dispatch_attachments ( + dispatch_id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + home_peer_fingerprint TEXT NOT NULL, + protocol_version INTEGER NOT NULL DEFAULT 1, + runtime_epoch TEXT NOT NULL, + capability_hash TEXT, + pane_key TEXT, + process_incarnation TEXT, + state TEXT NOT NULL DEFAULT 'starting' + CHECK(state IN ( + 'starting', 'ready', 'start_unknown', 'failed', 'succeeded', + 'stopping', 'stop_unknown', 'stopped', 'abandoned' + )), + stage TEXT NOT NULL DEFAULT 'accepted', + worktree_id TEXT, + terminal_handle TEXT, + setup_state TEXT NOT NULL DEFAULT 'not_applicable', + effects TEXT NOT NULL DEFAULT '[]', + residual_resources TEXT NOT NULL DEFAULT '[]', + to_worker_imported_sequence INTEGER NOT NULL DEFAULT 0, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `) + } + if (current < 15) { + if (!this.hasColumn('federated_dispatches', 'to_home_imported_sequence')) { + this.db.exec( + 'ALTER TABLE federated_dispatches ADD COLUMN to_home_imported_sequence INTEGER NOT NULL DEFAULT 0' + ) + } + if (!this.hasColumn('remote_dispatch_attachments', 'to_worker_imported_sequence')) { + this.db.exec( + 'ALTER TABLE remote_dispatch_attachments ADD COLUMN to_worker_imported_sequence INTEGER NOT NULL DEFAULT 0' + ) + } + this.db.exec(` + CREATE TABLE IF NOT EXISTS federation_relay_items ( + dispatch_id TEXT NOT NULL, + direction TEXT NOT NULL CHECK(direction IN ('to_home', 'to_worker')), + sequence INTEGER NOT NULL, + message_id TEXT NOT NULL, + kind TEXT NOT NULL, + payload TEXT NOT NULL, + byte_count INTEGER NOT NULL, + acked_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (dispatch_id, direction, sequence), + UNIQUE (dispatch_id, direction, message_id) + ); + CREATE INDEX IF NOT EXISTS idx_federation_relay_pending + ON federation_relay_items(dispatch_id, direction, acked_at, sequence); + `) + } + if (current < 16) { + this.db.exec(` + CREATE TABLE IF NOT EXISTS remote_questions ( + message_id TEXT PRIMARY KEY, + dispatch_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending', 'answered', 'closed')), + answer_message_id TEXT, + answer_body TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + answered_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_remote_questions_dispatch_status + ON remote_questions(dispatch_id, status); + `) + } + if (current < 17 && !this.hasColumn('remote_dispatch_attachments', 'protocol_version')) { + this.db.exec( + 'ALTER TABLE remote_dispatch_attachments ADD COLUMN protocol_version INTEGER NOT NULL DEFAULT 1' + ) + } this.createUndeliveredInboxIndexIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) @@ -320,9 +778,414 @@ export class OrchestrationDb { return !!row && row.sql.includes("'heartbeat'") } + private messagesTypeCheckAllowsQuestion(): boolean { + const row = this.db + .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'messages'") + .get() as { sql: string } | undefined + return !!row && row.sql.includes("'question'") + } + + // ── Durable mutation receipts ── + + beginMutationReceipt(params: { + callerFingerprint: string + requestId: string + method: string + payloadHash: string + }): + | { disposition: 'started'; row: MutationReceiptRow } + | { disposition: 'pending'; row: MutationReceiptRow } + | { disposition: 'completed'; row: MutationReceiptRow } { + this.db.exec('BEGIN IMMEDIATE') + try { + const existing = this.getMutationReceipt(params.callerFingerprint, params.requestId) + if (existing) { + if (existing.method !== params.method || existing.payload_hash !== params.payloadHash) { + throw new OrchestrationError( + 'request_mismatch', + `Mutation request ${params.requestId} was already used with different input.` + ) + } + this.db.exec('COMMIT') + return { disposition: existing.state, row: existing } + } + this.db + .prepare( + `INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES (?, ?, ?, ?, 'pending')` + ) + .run(params.callerFingerprint, params.requestId, params.method, params.payloadHash) + const row = this.getMutationReceipt(params.callerFingerprint, params.requestId) + this.db.exec('COMMIT') + return { disposition: 'started', row: row as MutationReceiptRow } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + completeMutationReceipt(params: { + callerFingerprint: string + requestId: string + method: string + payloadHash: string + receipt: string + }): MutationReceiptRow { + const result = this.db + .prepare( + `UPDATE mutation_receipts + SET state = 'completed', receipt = ?, updated_at = datetime('now') + WHERE caller_fingerprint = ? AND request_id = ? AND method = ? + AND payload_hash = ?` + ) + .run( + params.receipt, + params.callerFingerprint, + params.requestId, + params.method, + params.payloadHash + ) + const row = this.getMutationReceipt(params.callerFingerprint, params.requestId) + if (result.changes !== 1 || !row) { + throw new OrchestrationError( + 'request_mismatch', + `Mutation request ${params.requestId} no longer matches its pending operation.` + ) + } + return row + } + + discardPendingMutationReceipt(callerFingerprint: string, requestId: string): void { + this.db + .prepare( + `DELETE FROM mutation_receipts + WHERE caller_fingerprint = ? AND request_id = ? AND state = 'pending'` + ) + .run(callerFingerprint, requestId) + } + + getMutationReceipt(callerFingerprint: string, requestId: string): MutationReceiptRow | undefined { + return this.db + .prepare( + `SELECT * FROM mutation_receipts + WHERE caller_fingerprint = ? AND request_id = ?` + ) + .get(callerFingerprint, requestId) as MutationReceiptRow | undefined + } + + // ── Runs ── + + createRun(params: { + objective: string + coordinatorHandle: string + coordinatorPaneKey: string + }): RunRow { + const id = generateId('run') + this.db.exec('BEGIN IMMEDIATE') + try { + this.unbindOtherRunsForPane(params.coordinatorPaneKey) + this.db + .prepare( + `INSERT INTO runs ( + id, objective, coordinator_handle, coordinator_pane_key, + consumer_generation, legacy + ) VALUES (?, ?, ?, ?, 1, 0)` + ) + .run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey) + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + return this.getRun(id) as RunRow + } + + bindRun(params: { + runId: string + coordinatorHandle: string + coordinatorPaneKey: string + }): RunRow | undefined { + this.db.exec('BEGIN IMMEDIATE') + try { + const run = this.getRunRaw(params.runId) + if (!run || run.legacy === 1) { + this.db.exec('ROLLBACK') + return undefined + } + const sameBinding = + run.coordinator_pane_key !== null && + isEquivalentPaneKey(run.coordinator_pane_key, params.coordinatorPaneKey) + this.unbindOtherRunsForPane(params.coordinatorPaneKey, params.runId) + if (!sameBinding || run.coordinator_handle !== params.coordinatorHandle) { + this.db + .prepare( + `UPDATE runs + SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1, + updated_at = datetime('now') + WHERE id = ?` + ) + .run(params.coordinatorHandle, params.coordinatorPaneKey, params.runId) + this.fenceOutstandingDelivery(params.runId) + } + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + return this.getRun(params.runId) + } + + getRun(id: string): RunRow | undefined { + const run = this.getRunRaw(id) + return run ? exposeRunTimestamps(run) : undefined + } + + listRuns(): RunRow[] { + return (this.db.prepare('SELECT * FROM runs ORDER BY created_at DESC').all() as RunRow[]).map( + exposeRunTimestamps + ) + } + + getCurrentRunForPane(paneKey: string): RunRow | undefined { + const runs = this.db + .prepare('SELECT * FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0') + .all() as RunRow[] + const run = runs.find( + (candidate) => + candidate.coordinator_pane_key !== null && + isEquivalentPaneKey(candidate.coordinator_pane_key, paneKey) + ) + return run ? exposeRunTimestamps(run) : undefined + } + + private getRunRaw(id: string): RunRow | undefined { + return this.db.prepare('SELECT * FROM runs WHERE id = ?').get(id) as RunRow | undefined + } + + private unbindOtherRunsForPane(paneKey: string, exceptRunId?: string): void { + const bound = this.db + .prepare('SELECT * FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0') + .all() as RunRow[] + for (const run of bound) { + if ( + run.id !== exceptRunId && + run.coordinator_pane_key && + isEquivalentPaneKey(run.coordinator_pane_key, paneKey) + ) { + this.db + .prepare( + `UPDATE runs + SET coordinator_handle = NULL, coordinator_pane_key = NULL, + consumer_generation = consumer_generation + 1, + updated_at = datetime('now') + WHERE id = ?` + ) + .run(run.id) + this.fenceOutstandingDelivery(run.id) + } + } + } + + private requireRun(runId: string): void { + if (!this.getRunRaw(runId)) { + throw new Error(`Run not found: ${runId}`) + } + } + + private fenceOutstandingDelivery(runId: string): void { + this.db + .prepare( + "UPDATE deliveries SET status = 'fenced' WHERE run_id = ? AND status = 'outstanding'" + ) + .run(runId) + } + + private requireCurrentConsumer(runId: string, consumerGeneration: number): RunRow { + const run = this.getRunRaw(runId) + if (!run || run.legacy === 1 || run.consumer_generation !== consumerGeneration) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer has been replaced. Rebind with orchestration run-use.' + ) + } + return run + } + + private getDeliveryRaw(id: string): DeliveryRow | undefined { + return this.db.prepare('SELECT * FROM deliveries WHERE id = ?').get(id) as + | DeliveryRow + | undefined + } + + private getDeliveryMessages(delivery: DeliveryRow): MessageRow[] { + const ids = JSON.parse(delivery.message_ids) as string[] + if (ids.length === 0) { + return [] + } + const rows = this.db + .prepare(`SELECT * FROM messages WHERE id IN (${ids.map(() => '?').join(',')})`) + .all(...ids) as MessageRow[] + const byId = new Map(rows.map((row) => [row.id, row])) + return exposeMessageListTimestamps( + ids.map((id) => byId.get(id)).filter((row): row is MessageRow => row !== undefined) + ) + } + + getOrCreateRunDelivery(params: { + runId: string + consumerGeneration: number + limit?: number + wakeTypes?: MessageType[] + }): { delivery: DeliveryRow; messages: MessageRow[]; replayed: boolean } | undefined { + const limit = Math.min(Math.max(params.limit ?? 50, 1), 50) + this.db.exec('BEGIN IMMEDIATE') + try { + this.requireCurrentConsumer(params.runId, params.consumerGeneration) + const existing = this.db + .prepare("SELECT * FROM deliveries WHERE run_id = ? AND status = 'outstanding'") + .get(params.runId) as DeliveryRow | undefined + if (existing) { + if (existing.consumer_generation !== params.consumerGeneration) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox Delivery belongs to a fenced consumer generation.' + ) + } + const messages = this.getDeliveryMessages(existing) + this.db.exec('COMMIT') + return { delivery: exposeDeliveryTimestamps(existing), messages, replayed: true } + } + + const address = `run:${params.runId}` + if (params.wakeTypes && params.wakeTypes.length > 0) { + const placeholders = params.wakeTypes.map(() => '?').join(',') + const matching = this.db + .prepare( + `SELECT 1 FROM messages + WHERE run_id = ? AND to_handle = ? AND read = 0 + AND type IN (${placeholders}) LIMIT 1` + ) + .get(params.runId, address, ...params.wakeTypes) + if (!matching) { + this.db.exec('COMMIT') + return undefined + } + } + + const messages = exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages + WHERE run_id = ? AND to_handle = ? AND read = 0 + ORDER BY sequence ASC LIMIT ?` + ) + .all(params.runId, address, limit) as MessageRow[] + ) + if (messages.length === 0) { + this.db.exec('COMMIT') + return undefined + } + + const deliveryId = generateId('delivery') + this.db + .prepare( + `INSERT INTO deliveries (id, run_id, consumer_generation, message_ids) + VALUES (?, ?, ?, ?)` + ) + .run( + deliveryId, + params.runId, + params.consumerGeneration, + JSON.stringify(messages.map((message) => message.id)) + ) + const delivery = this.getDeliveryRaw(deliveryId) as DeliveryRow + this.db.exec('COMMIT') + return { delivery: exposeDeliveryTimestamps(delivery), messages, replayed: false } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + acknowledgeRunDelivery(params: { + runId: string + consumerGeneration: number + deliveryId: string + }): { delivery: DeliveryRow; duplicate: boolean } { + this.db.exec('BEGIN IMMEDIATE') + try { + this.requireCurrentConsumer(params.runId, params.consumerGeneration) + const delivery = this.getDeliveryRaw(params.deliveryId) + if (!delivery || delivery.run_id !== params.runId) { + throw new OrchestrationError( + 'stale_delivery', + `Delivery ${params.deliveryId} does not belong to this Run.` + ) + } + if ( + delivery.consumer_generation !== params.consumerGeneration || + delivery.status === 'fenced' + ) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox Delivery belongs to a fenced consumer generation.' + ) + } + if (delivery.status === 'acknowledged') { + this.db.exec('COMMIT') + return { delivery: exposeDeliveryTimestamps(delivery), duplicate: true } + } + + const messageIds = JSON.parse(delivery.message_ids) as string[] + if (messageIds.length > 0) { + const placeholders = messageIds.map(() => '?').join(',') + this.db + .prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`) + .run(...messageIds) + } + this.db + .prepare( + "UPDATE deliveries SET status = 'acknowledged', acknowledged_at = datetime('now') WHERE id = ?" + ) + .run(delivery.id) + const acknowledged = this.getDeliveryRaw(delivery.id) as DeliveryRow + this.db.exec('COMMIT') + return { delivery: exposeDeliveryTimestamps(acknowledged), duplicate: false } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + getRunMailboxHistory(runId: string, limit = 100, types?: MessageType[]): MessageRow[] { + const address = `run:${runId}` + if (types && types.length > 0) { + const placeholders = types.map(() => '?').join(',') + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE run_id = ? AND to_handle = ? + AND type IN (${placeholders}) ORDER BY sequence DESC LIMIT ?` + ) + .all(runId, address, ...types, limit) as MessageRow[] + ) + } + return exposeMessageListTimestamps( + this.db + .prepare( + `SELECT * FROM messages WHERE run_id = ? AND to_handle = ? + ORDER BY sequence DESC LIMIT ?` + ) + .all(runId, address, limit) as MessageRow[] + ) + } + // ── Messages ── insertMessage(msg: { + id?: string from: string to: string subject: string @@ -332,14 +1195,18 @@ export class OrchestrationDb { threadId?: string payload?: string senderPaneKey?: string + runId?: string }): MessageRow { - const id = generateId('msg') + const runId = msg.runId ?? LEGACY_RUN_ID + this.requireRun(runId) + const id = msg.id ?? generateId('msg') const stmt = this.db.prepare(` - INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload, sender_pane_key) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO messages (id, run_id, from_handle, to_handle, subject, body, type, priority, thread_id, payload, sender_pane_key) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) stmt.run( id, + runId, msg.from, msg.to, msg.subject, @@ -373,7 +1240,11 @@ export class OrchestrationDb { ) } - convertLifecycleMessageToRejection(messageId: string, reason: string): MessageRow | undefined { + convertLifecycleMessageToRejection( + messageId: string, + code: string, + reason: string + ): MessageRow | undefined { const message = this.getMessageById(messageId) if (!message || (message.type !== 'worker_done' && message.type !== 'heartbeat')) { return message @@ -381,7 +1252,7 @@ export class OrchestrationDb { const originalBody = message.body ? `\n\nOriginal body:\n${message.body}` : '' const body = `Orca rejected this ${message.type}: ${reason}${originalBody}` - const payload = addLifecycleRejectionMarker(message.payload, reason) + const payload = addLifecycleRejectionMarker(message.payload, code, reason) // Why: rejected lifecycle signals stay auditable but must not reach read paths as actionable completion/liveness events. this.db .prepare( @@ -508,6 +1379,156 @@ export class OrchestrationDb { ) } + createQuestion(params: { + runId: string + dispatchId: string + askerHandle: string + question: string + options?: string[] + }): { question: QuestionRow; message: MessageRow } { + this.db.exec('BEGIN IMMEDIATE') + try { + this.requireRun(params.runId) + const dispatch = this.getDispatchContextById(params.dispatchId) + if ( + !dispatch || + dispatch.run_id !== params.runId || + (dispatch.status !== 'pending' && dispatch.status !== 'dispatched') + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${params.dispatchId} is not active in Run ${params.runId}.` + ) + } + const message = this.insertMessage({ + from: `dispatch:${params.dispatchId}`, + to: `run:${params.runId}`, + subject: 'Question', + body: params.question, + type: 'question', + payload: JSON.stringify({ + taskId: dispatch.task_id, + dispatchId: dispatch.id, + question: params.question, + options: params.options ?? [] + }), + runId: params.runId + }) + this.db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(message.id, message.id) + this.db + .prepare( + `INSERT INTO question_threads ( + message_id, run_id, dispatch_id, asker_handle + ) VALUES (?, ?, ?, ?)` + ) + .run(message.id, params.runId, params.dispatchId, params.askerHandle) + const question = this.getQuestionRaw(message.id) as QuestionRow + const storedMessage = this.getMessageById(message.id) as MessageRow + this.db.exec('COMMIT') + return { question: exposeQuestionTimestamps(question), message: storedMessage } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + getQuestion(messageId: string): QuestionRow | undefined { + const question = this.getQuestionRaw(messageId) + return question ? exposeQuestionTimestamps(question) : undefined + } + + private getQuestionRaw(messageId: string): QuestionRow | undefined { + return this.db.prepare('SELECT * FROM question_threads WHERE message_id = ?').get(messageId) as + | QuestionRow + | undefined + } + + answerQuestion(params: { + messageId: string + runId: string + consumerGeneration: number + body: string + }): { question: QuestionRow; message: MessageRow; duplicate: boolean } { + this.db.exec('BEGIN IMMEDIATE') + try { + this.requireCurrentConsumer(params.runId, params.consumerGeneration) + const question = this.getQuestionRaw(params.messageId) + if (!question || question.run_id !== params.runId) { + throw new OrchestrationError( + 'question_not_found', + `Question ${params.messageId} was not found in Run ${params.runId}.` + ) + } + if (question.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${params.messageId} is closed because its Dispatch is inactive.` + ) + } + if (question.status === 'answered') { + if (question.answer_body !== params.body || !question.answer_message_id) { + throw new OrchestrationError( + 'answer_conflict', + `Question ${params.messageId} already has a different answer.` + ) + } + const message = this.getMessageById(question.answer_message_id) + if (!message) { + throw new Error(`Recorded answer message ${question.answer_message_id} was not found.`) + } + this.db.exec('COMMIT') + return { question: exposeQuestionTimestamps(question), message, duplicate: true } + } + + const message = this.insertMessage({ + from: `run:${params.runId}`, + to: `dispatch:${question.dispatch_id}`, + subject: 'Re: Question', + body: params.body, + threadId: question.message_id, + runId: params.runId + }) + // Why: ask returns thread state directly; leaving its answer unread would deliver it again via check. + this.markAsRead([message.id]) + this.db + .prepare( + `UPDATE question_threads + SET status = 'answered', answer_message_id = ?, answer_body = ?, + answered_by_generation = ?, answered_at = datetime('now') + WHERE message_id = ? AND status = 'pending'` + ) + .run(message.id, params.body, params.consumerGeneration, question.message_id) + const answered = this.getQuestionRaw(question.message_id) as QuestionRow + const storedMessage = this.getMessageById(message.id) as MessageRow + this.db.exec('COMMIT') + return { + question: exposeQuestionTimestamps(answered), + message: storedMessage, + duplicate: false + } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + closeQuestionsForDispatch(dispatchId: string): string[] { + const rows = this.db + .prepare( + "SELECT message_id FROM question_threads WHERE dispatch_id = ? AND status = 'pending'" + ) + .all(dispatchId) as { message_id: string }[] + if (rows.length === 0) { + return [] + } + this.db + .prepare( + "UPDATE question_threads SET status = 'closed', closed_at = datetime('now') WHERE dispatch_id = ? AND status = 'pending'" + ) + .run(dispatchId) + return rows.map((row) => row.message_id) + } + // ── Tasks ── createTask(task: { @@ -517,7 +1538,22 @@ export class OrchestrationDb { deps?: string[] parentId?: string createdByTerminalHandle?: string + runId?: string }): TaskRow { + const runId = task.runId ?? LEGACY_RUN_ID + this.requireRun(runId) + if (task.parentId) { + const parent = this.getTask(task.parentId) + if (!parent || parent.run_id !== runId) { + throw new Error(`Parent task ${task.parentId} must belong to run ${runId}`) + } + } + for (const depId of task.deps ?? []) { + const dependency = this.getTask(depId) + if (!dependency || dependency.run_id !== runId) { + throw new Error(`Dependency task ${depId} must belong to run ${runId}`) + } + } const id = generateId('task') const depsJson = JSON.stringify(task.deps ?? []) const hasDeps = (task.deps ?? []).length > 0 @@ -529,10 +1565,11 @@ export class OrchestrationDb { }) this.db .prepare( - 'INSERT INTO tasks (id, parent_id, created_by_terminal_handle, task_title, display_name, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + 'INSERT INTO tasks (id, run_id, parent_id, created_by_terminal_handle, task_title, display_name, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' ) .run( id, + runId, task.parentId ?? null, task.createdByTerminalHandle ?? null, display.taskTitle || null, @@ -548,27 +1585,42 @@ export class OrchestrationDb { return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined } - listTasks(filter?: { status?: TaskStatus; ready?: boolean }): TaskRow[] { + listTasks(filter?: { status?: TaskStatus; ready?: boolean; runId?: string }): TaskRow[] { + const runWhere = filter?.runId ? 'run_id = ? AND ' : '' + const runParams: Database.BindValue[] = filter?.runId ? [filter.runId] : [] if (filter?.ready) { return this.db - .prepare("SELECT * FROM tasks WHERE status = 'ready' ORDER BY created_at") - .all() as TaskRow[] + .prepare(`SELECT * FROM tasks WHERE ${runWhere}status = 'ready' ORDER BY created_at`) + .all(...runParams) as TaskRow[] } if (filter?.status) { return this.db - .prepare('SELECT * FROM tasks WHERE status = ? ORDER BY created_at') - .all(filter.status) as TaskRow[] + .prepare(`SELECT * FROM tasks WHERE ${runWhere}status = ? ORDER BY created_at`) + .all(...runParams, filter.status) as TaskRow[] + } + if (filter?.runId) { + return this.db + .prepare('SELECT * FROM tasks WHERE run_id = ? ORDER BY created_at') + .all(filter.runId) as TaskRow[] } return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[] } // Why: LEFT JOIN keeps non-dispatched tasks (NULL assignee); the MAX(rowid) subquery matches getDispatchContext's most-recent-active-dispatch semantics. - listTasksWithDispatch(filter?: { status?: TaskStatus; ready?: boolean }): (TaskRow & { + listTasksWithDispatch(filter?: { + status?: TaskStatus + ready?: boolean + runId?: string + }): (TaskRow & { assignee_handle: string | null dispatch_id: string | null })[] { const whereClauses: string[] = [] const params: Database.BindValue[] = [] + if (filter?.runId) { + whereClauses.push('t.run_id = ?') + params.push(filter.runId) + } if (filter?.ready) { whereClauses.push("t.status = 'ready'") } else if (filter?.status) { @@ -642,6 +1694,1498 @@ export class OrchestrationDb { // ── Dispatch Contexts ── + createStartingWorkerDispatch(params: { + taskId: string + startOptions: unknown + retryOf?: string + runtimeEpoch?: string + federation?: { + environmentId: string + environmentName: string + peerFingerprint: string + protocolVersion: number + } + mutationReceipt?: { + callerFingerprint: string + requestId: string + method: string + payloadHash: string + } + }): { dispatch: DispatchContextRow; worker: WorkerDispatchRow } { + this.db.exec('BEGIN IMMEDIATE') + try { + if (params.mutationReceipt) { + const receipt = params.mutationReceipt + const existing = this.getMutationReceipt(receipt.callerFingerprint, receipt.requestId) + if (existing) { + if (existing.method !== receipt.method || existing.payload_hash !== receipt.payloadHash) { + throw new OrchestrationError( + 'request_mismatch', + `Mutation request ${receipt.requestId} was already used with different input.` + ) + } + throw new OrchestrationError( + 'operation_unknown', + `Mutation ${receipt.requestId} already has a durable acceptance record.` + ) + } + this.db + .prepare( + `INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state + ) VALUES (?, ?, ?, ?, 'pending')` + ) + .run(receipt.callerFingerprint, receipt.requestId, receipt.method, receipt.payloadHash) + } + const task = this.getTask(params.taskId) + if (!task) { + throw new OrchestrationError('task_not_found', `Task ${params.taskId} was not found.`) + } + if (params.retryOf) { + const prior = this.getDispatchContextById(params.retryOf) + const priorWorker = this.getWorkerDispatch(params.retryOf) + const latest = this.getDispatchContext(task.id) + if ( + !prior || + prior.task_id !== task.id || + latest?.id !== prior.id || + !priorWorker || + !['failed', 'stopped', 'abandoned'].includes(priorWorker.state) || + !['failed', 'blocked'].includes(task.status) + ) { + throw new OrchestrationError( + 'task_not_startable', + `Task ${task.id} cannot retry from Dispatch ${params.retryOf}.` + ) + } + } else if (task.status !== 'ready') { + throw new OrchestrationError( + 'task_not_startable', + `Task ${task.id} is ${task.status}; only a ready Task can start.` + ) + } + + const id = generateId('ctx') + if (params.mutationReceipt) { + this.db + .prepare( + `UPDATE mutation_receipts + SET receipt = ?, updated_at = datetime('now') + WHERE caller_fingerprint = ? AND request_id = ? AND state = 'pending'` + ) + .run( + JSON.stringify({ accepted: { dispatchId: id } }), + params.mutationReceipt.callerFingerprint, + params.mutationReceipt.requestId + ) + } + this.db + .prepare( + `INSERT INTO dispatch_contexts ( + id, run_id, task_id, status, dispatched_at + ) VALUES (?, ?, ?, 'pending', datetime('now'))` + ) + .run(id, task.run_id, task.id) + this.db + .prepare( + `INSERT INTO worker_dispatches ( + dispatch_id, runtime_epoch, state, stage, start_options + ) VALUES (?, ?, 'starting', 'accepted', ?)` + ) + .run(id, params.runtimeEpoch ?? null, JSON.stringify(params.startOptions)) + if (params.federation) { + this.db + .prepare( + `INSERT INTO federated_dispatches ( + dispatch_id, environment_id, environment_name, peer_fingerprint, protocol_version + ) VALUES (?, ?, ?, ?, ?)` + ) + .run( + id, + params.federation.environmentId, + params.federation.environmentName, + params.federation.peerFingerprint, + params.federation.protocolVersion + ) + } + this.db + .prepare( + "UPDATE tasks SET status = 'dispatched', result = NULL, completed_at = NULL WHERE id = ?" + ) + .run(task.id) + this.db.exec('COMMIT') + return { + dispatch: this.getDispatchContextById(id) as DispatchContextRow, + worker: this.getWorkerDispatch(id) as WorkerDispatchRow + } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + recordWorkerStage(params: { + dispatchId: string + stage: string + worktreeId?: string + terminalHandle?: string + setupState?: string + effects?: unknown[] + residualResources?: unknown[] + lastError?: string + state?: WorkerDispatchState + }): WorkerDispatchRow { + const current = this.getWorkerDispatch(params.dispatchId) + if (!current) { + throw new OrchestrationError( + 'dispatch_not_found', + `Dispatch ${params.dispatchId} was not found.` + ) + } + this.db + .prepare( + `UPDATE worker_dispatches + SET stage = ?, state = ?, worktree_id = ?, agent_terminal_handle = ?, + setup_state = ?, effects = ?, residual_resources = ?, last_error = ?, + updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run( + params.stage, + params.state ?? current.state, + params.worktreeId ?? current.worktree_id, + params.terminalHandle ?? current.agent_terminal_handle, + params.setupState ?? current.setup_state, + params.effects ? JSON.stringify(params.effects) : current.effects, + params.residualResources + ? JSON.stringify(params.residualResources) + : current.residual_resources, + params.lastError ?? current.last_error, + params.dispatchId + ) + return this.getWorkerDispatch(params.dispatchId) as WorkerDispatchRow + } + + updateWorkerSetupEvidence(params: { + dispatchId: string + setupState: string + effects: unknown[] + }): { worker: WorkerDispatchRow; changed: boolean } { + const current = this.getWorkerDispatch(params.dispatchId) + if (!current) { + throw new OrchestrationError( + 'dispatch_not_found', + `Dispatch ${params.dispatchId} was not found.` + ) + } + const effects = JSON.stringify(params.effects) + if (current.setup_state === params.setupState && current.effects === effects) { + return { worker: current, changed: false } + } + this.db + .prepare( + `UPDATE worker_dispatches + SET setup_state = ?, effects = ?, updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run(params.setupState, effects, params.dispatchId) + return { + worker: this.getWorkerDispatch(params.dispatchId) as WorkerDispatchRow, + changed: true + } + } + + prepareStartingWorkerAuthority(params: { + dispatchId: string + handle: string + paneKey: string + processIncarnation: string + worktreeId: string + effects: unknown[] + setupState: string + }): string { + const dispatch = this.getDispatchContextById(params.dispatchId) + const worker = this.getWorkerDispatch(params.dispatchId) + if (!dispatch || dispatch.status !== 'pending' || worker?.state !== 'starting') { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${params.dispatchId} is not starting.` + ) + } + const capability = `dcap_${randomBytes(32).toString('base64url')}` + this.db.exec('BEGIN IMMEDIATE') + try { + this.db + .prepare( + `UPDATE dispatch_contexts + SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, + capability_hash = ?, capability_revoked_at = NULL + WHERE id = ? AND status = 'pending'` + ) + .run( + params.handle, + params.paneKey, + params.processIncarnation, + hashDispatchCapability(capability), + params.dispatchId + ) + this.db + .prepare( + `UPDATE worker_dispatches + SET stage = 'authority_attached', worktree_id = ?, agent_terminal_handle = ?, + setup_state = ?, effects = ?, residual_resources = ?, updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'starting'` + ) + .run( + params.worktreeId, + params.handle, + params.setupState, + JSON.stringify(params.effects), + JSON.stringify( + params.effects.filter((effect) => + Boolean( + effect && + typeof effect === 'object' && + ((effect as { action?: string }).action?.startsWith('created') || + (effect as { action?: string }).action === 'reused_agent_terminal') + ) + ) + ), + params.dispatchId + ) + this.db.exec('COMMIT') + return capability + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + markWorkerDispatchReady(dispatchId: string, effects?: unknown[]): WorkerDispatchRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const dispatch = this.getDispatchContextById(dispatchId) + const worker = this.getWorkerDispatch(dispatchId) + if (!dispatch || dispatch.status !== 'pending' || worker?.state !== 'starting') { + throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not starting.`) + } + this.db + .prepare("UPDATE dispatch_contexts SET status = 'dispatched' WHERE id = ?") + .run(dispatchId) + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'ready', stage = 'input_accepted', + effects = COALESCE(?, effects), updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run(effects ? JSON.stringify(effects) : null, dispatchId) + this.db.exec('COMMIT') + return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + failWorkerStart(dispatchId: string, stage: string, reason: string): WorkerDispatchRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const dispatch = this.getDispatchContextById(dispatchId) + const worker = this.getWorkerDispatch(dispatchId) + if (!dispatch || !worker || worker.state !== 'starting') { + throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not starting.`) + } + this.db + .prepare( + `UPDATE dispatch_contexts + SET status = 'failed', last_failure = ?, completed_at = datetime('now'), + capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE id = ?` + ) + .run(reason, dispatchId) + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'failed', stage = ?, last_error = ?, updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run(stage, reason, dispatchId) + this.db + .prepare("UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE id = ?") + .run(dispatch.task_id) + this.closeQuestionsForDispatch(dispatchId) + this.db.exec('COMMIT') + return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + markWorkerStartUnknown(dispatchId: string, stage: string, reason: string): WorkerDispatchRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const dispatch = this.getDispatchContextById(dispatchId) + const worker = this.getWorkerDispatch(dispatchId) + if (!dispatch || !worker || worker.state !== 'starting') { + throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not starting.`) + } + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'start_unknown', stage = ?, last_error = ?, updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run(stage, reason, dispatchId) + this.db + .prepare( + `UPDATE dispatch_contexts + SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE id = ?` + ) + .run(dispatchId) + this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id) + this.closeQuestionsForDispatch(dispatchId) + this.db.exec('COMMIT') + return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + reconcileFederatedWorkerStart(params: { + dispatchId: string + state: 'ready' | 'failed' | 'stopped' | 'start_unknown' + stage: string + lastError?: string | null + worktreeId?: string | null + terminalHandle?: string | null + setupState?: string + effects?: unknown[] + residualResources?: unknown[] + }): WorkerDispatchRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const dispatch = this.getDispatchContextById(params.dispatchId) + const worker = this.getWorkerDispatch(params.dispatchId) + if (!dispatch || !worker) { + throw new OrchestrationError( + 'dispatch_not_found', + `Federated Dispatch ${params.dispatchId} was not found.` + ) + } + if (!['starting', 'start_unknown'].includes(worker.state)) { + this.db.exec('COMMIT') + return worker + } + + if (params.state === 'ready') { + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'ready', stage = ?, worktree_id = COALESCE(?, worktree_id), + agent_terminal_handle = COALESCE(?, agent_terminal_handle), setup_state = ?, + effects = ?, residual_resources = ?, last_error = NULL, + updated_at = datetime('now') + WHERE dispatch_id = ? AND state IN ('starting', 'start_unknown')` + ) + .run( + params.stage, + params.worktreeId ?? null, + params.terminalHandle ?? null, + params.setupState ?? worker.setup_state, + JSON.stringify(params.effects ?? JSON.parse(worker.effects)), + JSON.stringify(params.residualResources ?? JSON.parse(worker.residual_resources)), + params.dispatchId + ) + this.db + .prepare( + "UPDATE dispatch_contexts SET status = 'dispatched' WHERE id = ? AND status = 'pending'" + ) + .run(params.dispatchId) + this.db + .prepare( + "UPDATE tasks SET status = 'dispatched', completed_at = NULL WHERE id = ? AND status = 'blocked'" + ) + .run(dispatch.task_id) + } else if (params.state === 'start_unknown') { + this.db + .prepare( + `UPDATE worker_dispatches + SET stage = ?, last_error = ?, updated_at = datetime('now') + WHERE dispatch_id = ? AND state IN ('starting', 'start_unknown')` + ) + .run(params.stage, params.lastError ?? worker.last_error, params.dispatchId) + } else { + const reason = params.lastError ?? `The worker server reported ${params.state}.` + this.db + .prepare( + `UPDATE worker_dispatches + SET state = ?, stage = ?, last_error = ?, updated_at = datetime('now') + WHERE dispatch_id = ? AND state IN ('starting', 'start_unknown')` + ) + .run(params.state, params.stage, reason, params.dispatchId) + this.db + .prepare( + `UPDATE dispatch_contexts + SET status = 'failed', last_failure = ?, completed_at = datetime('now'), + capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE id = ? AND status IN ('pending', 'dispatched')` + ) + .run(reason, params.dispatchId) + this.db + .prepare( + "UPDATE tasks SET status = 'failed', completed_at = datetime('now') WHERE id = ? AND status IN ('blocked', 'dispatched')" + ) + .run(dispatch.task_id) + this.closeQuestionsForDispatch(params.dispatchId) + } + this.db.exec('COMMIT') + return this.getWorkerDispatch(params.dispatchId) as WorkerDispatchRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + getWorkerDispatch(dispatchId: string): WorkerDispatchRow | undefined { + return this.db + .prepare('SELECT * FROM worker_dispatches WHERE dispatch_id = ?') + .get(dispatchId) as WorkerDispatchRow | undefined + } + + getFederatedDispatch(dispatchId: string): FederatedDispatchRow | undefined { + return this.db + .prepare('SELECT * FROM federated_dispatches WHERE dispatch_id = ?') + .get(dispatchId) as FederatedDispatchRow | undefined + } + + listActiveFederatedDispatches(runId?: string): FederatedDispatchRow[] { + return this.db + .prepare( + `SELECT fd.* + FROM federated_dispatches fd + INNER JOIN dispatch_contexts dc ON dc.id = fd.dispatch_id + INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id + WHERE wd.state IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown') + AND (? IS NULL OR dc.run_id = ?) + ORDER BY fd.rowid` + ) + .all(runId ?? null, runId ?? null) as FederatedDispatchRow[] + } + + updateFederatedDispatchResources(params: { + dispatchId: string + remoteRuntimeEpoch: string + worktreeId: string + terminalHandle: string + }): FederatedDispatchRow { + this.db + .prepare( + `UPDATE federated_dispatches + SET remote_runtime_epoch = ?, remote_worktree_id = ?, remote_terminal_handle = ?, + updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run(params.remoteRuntimeEpoch, params.worktreeId, params.terminalHandle, params.dispatchId) + const row = this.getFederatedDispatch(params.dispatchId) + if (!row) { + throw new OrchestrationError( + 'dispatch_not_found', + `Federated Dispatch ${params.dispatchId} was not found.` + ) + } + return row + } + + createRemoteDispatchAttachment(params: { + dispatchId: string + taskId: string + homePeerFingerprint: string + protocolVersion: number + runtimeEpoch: string + mutationReceipt: { + callerFingerprint: string + requestId: string + method: string + payloadHash: string + } + }): RemoteDispatchAttachmentRow { + this.db.exec('BEGIN IMMEDIATE') + try { + if (params.homePeerFingerprint !== params.mutationReceipt.callerFingerprint) { + throw new OrchestrationError( + 'resource_server_mismatch', + 'The authenticated Run-home peer does not match the attachment request.' + ) + } + const existingReceipt = this.getMutationReceipt( + params.mutationReceipt.callerFingerprint, + params.mutationReceipt.requestId + ) + if (existingReceipt) { + throw new OrchestrationError( + existingReceipt.method === params.mutationReceipt.method && + existingReceipt.payload_hash === params.mutationReceipt.payloadHash + ? 'operation_unknown' + : 'request_mismatch', + `Remote attachment request ${params.mutationReceipt.requestId} already exists.` + ) + } + this.db + .prepare( + `INSERT INTO mutation_receipts ( + caller_fingerprint, request_id, method, payload_hash, state, receipt + ) VALUES (?, ?, ?, ?, 'pending', ?)` + ) + .run( + params.mutationReceipt.callerFingerprint, + params.mutationReceipt.requestId, + params.mutationReceipt.method, + params.mutationReceipt.payloadHash, + JSON.stringify({ accepted: { dispatchId: params.dispatchId } }) + ) + this.db + .prepare( + `INSERT INTO remote_dispatch_attachments ( + dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch + ) VALUES (?, ?, ?, ?, ?)` + ) + .run( + params.dispatchId, + params.taskId, + params.homePeerFingerprint, + params.protocolVersion, + params.runtimeEpoch + ) + this.db.exec('COMMIT') + return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + getRemoteDispatchAttachment(dispatchId: string): RemoteDispatchAttachmentRow | undefined { + return this.db + .prepare('SELECT * FROM remote_dispatch_attachments WHERE dispatch_id = ?') + .get(dispatchId) as RemoteDispatchAttachmentRow | undefined + } + + recordRemoteAttachmentStage(params: { + dispatchId: string + stage: string + state?: WorkerDispatchState + worktreeId?: string + terminalHandle?: string + setupState?: string + effects?: unknown[] + residualResources?: unknown[] + lastError?: string + }): RemoteDispatchAttachmentRow { + const current = this.getRemoteDispatchAttachment(params.dispatchId) + if (!current) { + throw new OrchestrationError( + 'dispatch_not_found', + `Remote Dispatch ${params.dispatchId} was not found.` + ) + } + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET stage = ?, state = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?, + effects = ?, residual_resources = ?, last_error = ?, updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run( + params.stage, + params.state ?? current.state, + params.worktreeId ?? current.worktree_id, + params.terminalHandle ?? current.terminal_handle, + params.setupState ?? current.setup_state, + params.effects ? JSON.stringify(params.effects) : current.effects, + params.residualResources + ? JSON.stringify(params.residualResources) + : current.residual_resources, + params.lastError ?? current.last_error, + params.dispatchId + ) + return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow + } + + updateRemoteAttachmentSetupEvidence(params: { + dispatchId: string + setupState: string + effects: unknown[] + }): { attachment: RemoteDispatchAttachmentRow; changed: boolean } { + const current = this.getRemoteDispatchAttachment(params.dispatchId) + if (!current) { + throw new OrchestrationError( + 'dispatch_not_found', + `Remote Dispatch ${params.dispatchId} was not found.` + ) + } + const effects = JSON.stringify(params.effects) + if (current.setup_state === params.setupState && current.effects === effects) { + return { attachment: current, changed: false } + } + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET setup_state = ?, effects = ?, updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run(params.setupState, effects, params.dispatchId) + return { + attachment: this.getRemoteDispatchAttachment( + params.dispatchId + ) as RemoteDispatchAttachmentRow, + changed: true + } + } + + prepareRemoteAttachmentAuthority(params: { + dispatchId: string + paneKey: string + processIncarnation: string + worktreeId: string + terminalHandle: string + setupState: string + effects: unknown[] + }): string { + const attachment = this.getRemoteDispatchAttachment(params.dispatchId) + if (!attachment || attachment.state !== 'starting') { + throw new OrchestrationError( + 'dispatch_inactive', + `Remote Dispatch ${params.dispatchId} is not starting.` + ) + } + const capability = `dcap_${randomBytes(32).toString('base64url')}` + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET stage = 'authority_attached', capability_hash = ?, pane_key = ?, + process_incarnation = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?, + effects = ?, residual_resources = ?, updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'starting'` + ) + .run( + hashDispatchCapability(capability), + params.paneKey, + params.processIncarnation, + params.worktreeId, + params.terminalHandle, + params.setupState, + JSON.stringify(params.effects), + JSON.stringify( + params.effects.filter((effect) => + Boolean( + effect && + typeof effect === 'object' && + ((effect as { action?: string }).action?.startsWith('created') || + (effect as { action?: string }).action === 'reused_agent_terminal') + ) + ) + ), + params.dispatchId + ) + return capability + } + + markRemoteAttachmentReady(dispatchId: string, effects?: unknown[]): RemoteDispatchAttachmentRow { + const result = this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET state = 'ready', stage = 'input_accepted', + effects = COALESCE(?, effects), updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'starting'` + ) + .run(effects ? JSON.stringify(effects) : null, dispatchId) + if (result.changes !== 1) { + throw new OrchestrationError( + 'dispatch_inactive', + `Remote Dispatch ${dispatchId} is not starting.` + ) + } + return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow + } + + failRemoteAttachment( + dispatchId: string, + stage: string, + reason: string, + unknown: boolean + ): RemoteDispatchAttachmentRow { + const state = unknown ? 'start_unknown' : 'failed' + const result = this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET state = ?, stage = ?, last_error = ?, capability_hash = NULL, + updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'starting'` + ) + .run(state, stage, reason, dispatchId) + if (result.changes !== 1) { + throw new OrchestrationError( + 'dispatch_inactive', + `Remote Dispatch ${dispatchId} is not starting.` + ) + } + return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow + } + + verifyRemoteAttachmentAuthority(params: { + dispatchId: string + capability: string | undefined + paneKey: string | null + processIncarnation: string | null + }): boolean { + const attachment = this.getRemoteDispatchAttachment(params.dispatchId) + if ( + !attachment?.capability_hash || + !params.capability || + !attachment.pane_key || + !params.paneKey || + !isEquivalentPaneKey(attachment.pane_key, params.paneKey) || + !attachment.process_incarnation || + attachment.process_incarnation !== params.processIncarnation + ) { + return false + } + const expected = Buffer.from(attachment.capability_hash, 'hex') + const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex') + return expected.length === observed.length && timingSafeEqual(expected, observed) + } + + isRemoteAttachmentProcessCurrent(params: { + dispatchId: string + paneKey: string | null + processIncarnation: string | null + }): boolean { + const attachment = this.getRemoteDispatchAttachment(params.dispatchId) + return Boolean( + attachment?.pane_key && + params.paneKey && + isEquivalentPaneKey(attachment.pane_key, params.paneKey) && + attachment.process_incarnation && + attachment.process_incarnation === params.processIncarnation + ) + } + + beginRemoteAttachmentStop(dispatchId: string): RemoteDispatchAttachmentRow { + const attachment = this.getRemoteDispatchAttachment(dispatchId) + if (!attachment) { + throw new OrchestrationError( + 'dispatch_not_found', + `Remote Dispatch ${dispatchId} was not found.` + ) + } + if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(attachment.state)) { + return attachment + } + if (!['ready', 'start_unknown'].includes(attachment.state)) { + throw new OrchestrationError( + 'dispatch_inactive', + `Remote Dispatch ${dispatchId} cannot stop from ${attachment.state}.` + ) + } + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET state = 'stopping', stage = 'stop_requested', capability_hash = NULL, + updated_at = datetime('now') + WHERE dispatch_id = ? AND state IN ('ready', 'start_unknown')` + ) + .run(dispatchId) + return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow + } + + settleRemoteAttachmentStop(dispatchId: string): RemoteDispatchAttachmentRow { + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET state = 'stopped', stage = 'process_stopped', updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'stopping'` + ) + .run(dispatchId) + return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow + } + + markRemoteAttachmentStopUnknown(dispatchId: string, reason: string): RemoteDispatchAttachmentRow { + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET state = 'stop_unknown', stage = 'stop_outcome_unknown', last_error = ?, + updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'stopping'` + ) + .run(reason, dispatchId) + return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow + } + + findActiveRemoteAttachmentForPane(paneKey: string): RemoteDispatchAttachmentRow | undefined { + const rows = this.db + .prepare( + `SELECT * FROM remote_dispatch_attachments + WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL + ORDER BY rowid DESC` + ) + .all() as RemoteDispatchAttachmentRow[] + return rows.find((row) => row.pane_key && isEquivalentPaneKey(row.pane_key, paneKey)) + } + + enqueueFederationRelay(params: { + dispatchId: string + direction: FederationRelayDirection + kind: string + payload: string + messageId?: string + settleRemoteOutcome?: WorkerReportOutcome + remoteQuestion?: true + }): FederationRelayItemRow { + const byteCount = Buffer.byteLength(params.payload, 'utf8') + const messageId = params.messageId ?? generateId('relay') + if (byteCount > 64 * 1024) { + throw new OrchestrationError( + 'relay_quota_exceeded', + 'A federated orchestration message cannot exceed 64 KiB.' + ) + } + this.db.exec('BEGIN IMMEDIATE') + try { + if (params.settleRemoteOutcome) { + const attachment = this.getRemoteDispatchAttachment(params.dispatchId) + if (!attachment || attachment.state !== 'ready') { + throw new OrchestrationError( + 'dispatch_inactive', + `Remote Dispatch ${params.dispatchId} is not active.` + ) + } + } + if (params.kind === 'heartbeat') { + const heartbeat = this.db + .prepare( + `SELECT * FROM federation_relay_items + WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat' + AND acked_at IS NULL + ORDER BY sequence DESC LIMIT 1` + ) + .get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined + if (heartbeat) { + this.db + .prepare( + `UPDATE federation_relay_items + SET payload = ?, byte_count = ?, created_at = datetime('now') + WHERE dispatch_id = ? AND direction = ? AND sequence = ?` + ) + .run(params.payload, byteCount, params.dispatchId, params.direction, heartbeat.sequence) + this.db.exec('COMMIT') + return this.getFederationRelayItem( + params.dispatchId, + params.direction, + heartbeat.sequence + ) as FederationRelayItemRow + } + } + const quota = this.db + .prepare( + `SELECT COUNT(*) AS count, COALESCE(SUM(byte_count), 0) AS bytes + FROM federation_relay_items + WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL` + ) + .get(params.dispatchId, params.direction) as { count: number; bytes: number } + if (quota.count >= 256 || quota.bytes + byteCount > 1024 * 1024) { + if (params.kind === 'worker_done') { + const heartbeat = this.db + .prepare( + `SELECT * FROM federation_relay_items + WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat' + AND acked_at IS NULL + ORDER BY sequence LIMIT 1` + ) + .get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined + if (heartbeat) { + this.db + .prepare( + `UPDATE federation_relay_items + SET message_id = ?, kind = ?, payload = ?, byte_count = ?, + created_at = datetime('now') + WHERE dispatch_id = ? AND direction = ? AND sequence = ?` + ) + .run( + messageId, + params.kind, + params.payload, + byteCount, + params.dispatchId, + params.direction, + heartbeat.sequence + ) + this.settleRemoteAttachmentInRelayTransaction( + params.dispatchId, + params.settleRemoteOutcome + ) + this.db.exec('COMMIT') + return this.getFederationRelayItem( + params.dispatchId, + params.direction, + heartbeat.sequence + ) as FederationRelayItemRow + } + } + throw new OrchestrationError( + 'relay_quota_exceeded', + `Federated Dispatch ${params.dispatchId} has no relay capacity.` + ) + } + const latest = this.db + .prepare( + `SELECT COALESCE(MAX(sequence), 0) AS sequence + FROM federation_relay_items WHERE dispatch_id = ? AND direction = ?` + ) + .get(params.dispatchId, params.direction) as { sequence: number } + const sequence = latest.sequence + 1 + this.db + .prepare( + `INSERT INTO federation_relay_items ( + dispatch_id, direction, sequence, message_id, kind, payload, byte_count + ) VALUES (?, ?, ?, ?, ?, ?, ?)` + ) + .run( + params.dispatchId, + params.direction, + sequence, + messageId, + params.kind, + params.payload, + byteCount + ) + if (params.remoteQuestion) { + this.db + .prepare( + `INSERT INTO remote_questions (message_id, dispatch_id) + VALUES (?, ?)` + ) + .run(messageId, params.dispatchId) + } + this.settleRemoteAttachmentInRelayTransaction(params.dispatchId, params.settleRemoteOutcome) + this.db.exec('COMMIT') + return this.getFederationRelayItem( + params.dispatchId, + params.direction, + sequence + ) as FederationRelayItemRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + listFederationRelay(params: { + dispatchId: string + direction: FederationRelayDirection + afterSequence: number + limit?: number + }): FederationRelayItemRow[] { + return this.db + .prepare( + `SELECT * FROM federation_relay_items + WHERE dispatch_id = ? AND direction = ? AND sequence > ? + ORDER BY sequence LIMIT ?` + ) + .all( + params.dispatchId, + params.direction, + params.afterSequence, + Math.min(Math.max(params.limit ?? 50, 1), 50) + ) as FederationRelayItemRow[] + } + + listPendingFederationRelay( + dispatchId: string, + direction: FederationRelayDirection, + limit = 50 + ): FederationRelayItemRow[] { + return this.db + .prepare( + `SELECT * FROM federation_relay_items + WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL + ORDER BY sequence LIMIT ?` + ) + .all(dispatchId, direction, Math.min(Math.max(limit, 1), 50)) as FederationRelayItemRow[] + } + + acknowledgeFederationRelay(params: { + dispatchId: string + direction: FederationRelayDirection + throughSequence: number + }): void { + this.db + .prepare( + `UPDATE federation_relay_items SET acked_at = COALESCE(acked_at, datetime('now')) + WHERE dispatch_id = ? AND direction = ? AND sequence <= ?` + ) + .run(params.dispatchId, params.direction, params.throughSequence) + } + + setFederatedHomeImportSequence(dispatchId: string, sequence: number): void { + this.db + .prepare( + `UPDATE federated_dispatches + SET to_home_imported_sequence = ?, updated_at = datetime('now') + WHERE dispatch_id = ? AND to_home_imported_sequence < ?` + ) + .run(sequence, dispatchId, sequence) + } + + importFederatedRelayItem(params: { + dispatchId: string + sequence: number + message: { + id: string + runId: string + from: string + to: string + subject: string + body: string + type: MessageType + priority: MessagePriority + threadId?: string + payload?: string + } + lifecycle: + | { kind: 'none' } + | { kind: 'heartbeat'; at: string } + | { + kind: 'worker_report' + taskId: string + outcome: WorkerReportOutcome + result: string + } + | { kind: 'rejected'; code: string; reason: string } + }): { message: MessageRow; duplicate: boolean } { + this.db.exec('BEGIN IMMEDIATE') + try { + const federated = this.getFederatedDispatch(params.dispatchId) + if (!federated) { + throw new OrchestrationError( + 'dispatch_not_found', + `Federated Dispatch ${params.dispatchId} was not found.` + ) + } + if (params.sequence <= federated.to_home_imported_sequence) { + const existing = this.getMessageById(params.message.id) + if (!existing) { + throw new OrchestrationError( + 'operation_unknown', + `Federated relay sequence ${params.sequence} was committed without its message.` + ) + } + this.db.exec('COMMIT') + return { message: existing, duplicate: true } + } + if (params.sequence !== federated.to_home_imported_sequence + 1) { + throw new OrchestrationError( + 'operation_unknown', + `Federated relay for ${params.dispatchId} is not contiguous after sequence ${federated.to_home_imported_sequence}.` + ) + } + + let message = this.getMessageById(params.message.id) + if (!message) { + message = this.insertMessage(params.message) + } else if ( + message.run_id !== params.message.runId || + message.to_handle !== params.message.to || + message.type !== params.message.type + ) { + throw new OrchestrationError( + 'request_mismatch', + `Federated relay message ${params.message.id} conflicts with an existing message.` + ) + } + if (message.type === 'question') { + this.registerFederatedQuestion({ + messageId: message.id, + runId: params.message.runId, + dispatchId: params.dispatchId + }) + } + if (params.lifecycle.kind === 'heartbeat') { + this.recordHeartbeat(params.dispatchId, params.lifecycle.at) + } else if (params.lifecycle.kind === 'worker_report') { + const settlement = this.settleWorkerReportInTransaction({ + taskId: params.lifecycle.taskId, + dispatchId: params.dispatchId, + outcome: params.lifecycle.outcome, + result: params.lifecycle.result + }) + if (settlement.action === 'rejected') { + message = this.convertLifecycleMessageToRejection( + message.id, + settlement.code, + settlement.reason + ) as MessageRow + } + } else if (params.lifecycle.kind === 'rejected') { + message = this.convertLifecycleMessageToRejection( + message.id, + params.lifecycle.code, + params.lifecycle.reason + ) as MessageRow + } + this.setFederatedHomeImportSequence(params.dispatchId, params.sequence) + this.db.exec('COMMIT') + return { message, duplicate: false } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + getRemoteQuestion(messageId: string): + | { + message_id: string + dispatch_id: string + status: 'pending' | 'answered' | 'closed' + answer_message_id: string | null + answer_body: string | null + } + | undefined { + return this.db.prepare('SELECT * FROM remote_questions WHERE message_id = ?').get(messageId) as + | { + message_id: string + dispatch_id: string + status: 'pending' | 'answered' | 'closed' + answer_message_id: string | null + answer_body: string | null + } + | undefined + } + + answerRemoteQuestion(params: { + messageId: string + dispatchId: string + answerMessageId: string + body: string + }): void { + const question = this.getRemoteQuestion(params.messageId) + if (!question || question.dispatch_id !== params.dispatchId) { + throw new OrchestrationError( + 'question_not_found', + `Remote Question ${params.messageId} was not found.` + ) + } + if (question.status === 'answered') { + if ( + question.answer_message_id !== params.answerMessageId || + question.answer_body !== params.body + ) { + throw new OrchestrationError( + 'answer_conflict', + `Remote Question ${params.messageId} already has a different answer.` + ) + } + return + } + this.db + .prepare( + `UPDATE remote_questions + SET status = 'answered', answer_message_id = ?, answer_body = ?, + answered_at = datetime('now') + WHERE message_id = ? AND status = 'pending'` + ) + .run(params.answerMessageId, params.body, params.messageId) + } + + setRemoteWorkerImportSequence(dispatchId: string, sequence: number): void { + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET to_worker_imported_sequence = ?, updated_at = datetime('now') + WHERE dispatch_id = ? AND to_worker_imported_sequence < ?` + ) + .run(sequence, dispatchId, sequence) + } + + registerFederatedQuestion(params: { + messageId: string + runId: string + dispatchId: string + }): void { + this.db + .prepare( + `INSERT OR IGNORE INTO question_threads ( + message_id, run_id, dispatch_id, asker_handle + ) VALUES (?, ?, ?, ?)` + ) + .run(params.messageId, params.runId, params.dispatchId, `dispatch:${params.dispatchId}`) + } + + private getFederationRelayItem( + dispatchId: string, + direction: FederationRelayDirection, + sequence: number + ): FederationRelayItemRow | undefined { + return this.db + .prepare( + `SELECT * FROM federation_relay_items + WHERE dispatch_id = ? AND direction = ? AND sequence = ?` + ) + .get(dispatchId, direction, sequence) as FederationRelayItemRow | undefined + } + + private settleRemoteAttachmentInRelayTransaction( + dispatchId: string, + outcome: WorkerReportOutcome | undefined + ): void { + if (!outcome) { + return + } + this.db + .prepare( + `UPDATE remote_dispatch_attachments + SET state = ?, stage = 'worker_report_queued', capability_hash = NULL, + updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'ready'` + ) + .run(outcome === 'succeeded' ? 'succeeded' : 'failed', dispatchId) + } + + isDispatchProcessCurrent(params: { + dispatchId: string + paneKey: string | null + processIncarnation: string | null + }): boolean { + const dispatch = this.getDispatchContextById(params.dispatchId) + return Boolean( + dispatch?.assignee_pane_key && + params.paneKey && + isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey) && + dispatch.process_incarnation && + params.processIncarnation === dispatch.process_incarnation + ) + } + + beginWorkerStop( + dispatchId: string + ): + | { disposition: 'stopping'; worker: WorkerDispatchRow; dispatch: DispatchContextRow } + | { disposition: 'already_settled'; worker: WorkerDispatchRow; dispatch: DispatchContextRow } { + this.db.exec('BEGIN IMMEDIATE') + try { + const dispatch = this.getDispatchContextById(dispatchId) + const worker = this.getWorkerDispatch(dispatchId) + if (!dispatch || !worker) { + throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`) + } + if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(worker.state)) { + this.db.exec('COMMIT') + return { disposition: 'already_settled', worker, dispatch } + } + if (!['ready', 'start_unknown'].includes(worker.state)) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${dispatchId} cannot stop from ${worker.state}.` + ) + } + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'stopping', stage = 'stop_requested', updated_at = datetime('now') + WHERE dispatch_id = ? AND state IN ('ready', 'start_unknown')` + ) + .run(dispatchId) + this.db + .prepare( + `UPDATE dispatch_contexts + SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE id = ?` + ) + .run(dispatchId) + this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id) + this.closeQuestionsForDispatch(dispatchId) + this.db.exec('COMMIT') + return { + disposition: 'stopping', + worker: this.getWorkerDispatch(dispatchId) as WorkerDispatchRow, + dispatch: this.getDispatchContextById(dispatchId) as DispatchContextRow + } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + settleWorkerStop(dispatchId: string): WorkerDispatchRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const worker = this.getWorkerDispatch(dispatchId) + const dispatch = this.getDispatchContextById(dispatchId) + if (!worker || !dispatch || worker.state !== 'stopping') { + throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not stopping.`) + } + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'stopped', stage = 'process_stopped', updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'stopping'` + ) + .run(dispatchId) + this.db + .prepare( + `UPDATE dispatch_contexts + SET status = 'failed', completed_at = datetime('now'), last_failure = 'stopped' + WHERE id = ? AND status IN ('pending', 'dispatched')` + ) + .run(dispatchId) + this.db.exec('COMMIT') + return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + reconcileFederatedWorkerStop(dispatchId: string): WorkerDispatchRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const worker = this.getWorkerDispatch(dispatchId) + const dispatch = this.getDispatchContextById(dispatchId) + if (!worker || !dispatch || !this.getFederatedDispatch(dispatchId)) { + throw new OrchestrationError( + 'dispatch_not_found', + `Federated Dispatch ${dispatchId} was not found.` + ) + } + if (worker.state === 'stopped') { + this.db.exec('COMMIT') + return worker + } + if (!['stopping', 'stop_unknown'].includes(worker.state)) { + throw new OrchestrationError( + 'dispatch_inactive', + `Federated Dispatch ${dispatchId} cannot reconcile stop from ${worker.state}.` + ) + } + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'stopped', stage = 'process_stopped', last_error = NULL, + updated_at = datetime('now') + WHERE dispatch_id = ? AND state IN ('stopping', 'stop_unknown')` + ) + .run(dispatchId) + this.db + .prepare( + `UPDATE dispatch_contexts + SET status = 'failed', completed_at = COALESCE(completed_at, datetime('now')), + last_failure = 'stopped' + WHERE id = ? AND status IN ('pending', 'dispatched')` + ) + .run(dispatchId) + this.db.exec('COMMIT') + return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + resumeFederatedWorkerForTerminalRelay(dispatchId: string): WorkerDispatchRow { + this.db.exec('BEGIN IMMEDIATE') + try { + const worker = this.getWorkerDispatch(dispatchId) + const dispatch = this.getDispatchContextById(dispatchId) + if (!worker || !dispatch || worker.state !== 'stopping') { + throw new OrchestrationError('dispatch_inactive', `Dispatch ${dispatchId} is not stopping.`) + } + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'ready', stage = 'remote_report_pending', updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'stopping'` + ) + .run(dispatchId) + this.db + .prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ? AND status = 'blocked'") + .run(dispatch.task_id) + this.db.exec('COMMIT') + return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + markWorkerStopUnknown(dispatchId: string, reason: string): WorkerDispatchRow { + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'stop_unknown', stage = 'stop_outcome_unknown', last_error = ?, + updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'stopping'` + ) + .run(reason, dispatchId) + return this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } + + abandonWorkerDispatch(dispatchId: string): { + disposition: 'abandoned' | 'already_abandoned' | 'stale' + worker: WorkerDispatchRow + } { + this.db.exec('BEGIN IMMEDIATE') + try { + const worker = this.getWorkerDispatch(dispatchId) + const dispatch = this.getDispatchContextById(dispatchId) + if (!worker || !dispatch) { + throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`) + } + if (worker.state === 'abandoned') { + this.db.exec('COMMIT') + return { disposition: 'already_abandoned', worker } + } + if (this.getDispatchContext(dispatch.task_id)?.id !== dispatchId) { + this.db.exec('COMMIT') + return { disposition: 'stale', worker } + } + if (worker.state === 'succeeded') { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${dispatchId} already succeeded and cannot be abandoned.` + ) + } + this.db + .prepare( + `UPDATE worker_dispatches + SET state = 'abandoned', stage = 'abandoned', updated_at = datetime('now') + WHERE dispatch_id = ?` + ) + .run(dispatchId) + this.db + .prepare( + `UPDATE dispatch_contexts + SET status = CASE WHEN status IN ('pending', 'dispatched') THEN 'failed' ELSE status END, + capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')), + completed_at = COALESCE(completed_at, datetime('now')) + WHERE id = ?` + ) + .run(dispatchId) + this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(dispatch.task_id) + this.closeQuestionsForDispatch(dispatchId) + this.db.exec('COMMIT') + return { + disposition: 'abandoned', + worker: this.getWorkerDispatch(dispatchId) as WorkerDispatchRow + } + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + createDispatchContext( taskId: string, assigneeHandle: string, @@ -674,10 +3218,10 @@ export class OrchestrationDb { const id = generateId('ctx') this.db .prepare( - `INSERT INTO dispatch_contexts (id, task_id, assignee_handle, assignee_pane_key, status, failure_count, dispatched_at) - VALUES (?, ?, ?, ?, 'dispatched', ?, datetime('now'))` + `INSERT INTO dispatch_contexts (id, run_id, task_id, assignee_handle, assignee_pane_key, status, failure_count, dispatched_at) + VALUES (?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))` ) - .run(id, taskId, assigneeHandle, assigneePaneKey ?? null, priorFailures) + .run(id, task.run_id, taskId, assigneeHandle, assigneePaneKey ?? null, priorFailures) this.hasAnyDispatchContextsCache = true this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId) @@ -699,6 +3243,86 @@ export class OrchestrationDb { | undefined } + mintDispatchCapability(params: { + dispatchId: string + paneKey: string + processIncarnation: string + }): string { + const dispatch = this.getDispatchContextById(params.dispatchId) + if (!dispatch || (dispatch.status !== 'pending' && dispatch.status !== 'dispatched')) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${params.dispatchId} is not active.` + ) + } + const capability = `dcap_${randomBytes(32).toString('base64url')}` + this.db + .prepare( + `UPDATE dispatch_contexts + SET capability_hash = ?, assignee_pane_key = ?, process_incarnation = ?, + capability_revoked_at = NULL + WHERE id = ?` + ) + .run( + hashDispatchCapability(capability), + params.paneKey, + params.processIncarnation, + params.dispatchId + ) + return capability + } + + verifyDispatchCapability(params: { + dispatchId: string + capability: string | undefined + paneKey: string | undefined + processIncarnation: string | undefined + }): { valid: true } | { valid: false; reason: string } { + const dispatch = this.getDispatchContextById(params.dispatchId) + if (!dispatch) { + return { valid: false, reason: `Dispatch ${params.dispatchId} was not found.` } + } + if (!dispatch.capability_hash) { + return { valid: false, reason: `Dispatch ${params.dispatchId} has no lifecycle capability.` } + } + if (dispatch.capability_revoked_at) { + return { valid: false, reason: `Dispatch ${params.dispatchId} capability is revoked.` } + } + if (!params.capability) { + return { valid: false, reason: 'The Dispatch capability is missing.' } + } + const expected = Buffer.from(dispatch.capability_hash, 'hex') + const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex') + if (expected.length !== observed.length || !timingSafeEqual(expected, observed)) { + return { valid: false, reason: 'The Dispatch capability is invalid.' } + } + if ( + !dispatch.assignee_pane_key || + !params.paneKey || + !isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey) + ) { + return { valid: false, reason: 'The caller is not the Dispatch pane.' } + } + if ( + !dispatch.process_incarnation || + !params.processIncarnation || + dispatch.process_incarnation !== params.processIncarnation + ) { + return { valid: false, reason: 'The Dispatch process incarnation changed.' } + } + return { valid: true } + } + + revokeDispatchCapability(dispatchId: string): void { + this.db + .prepare( + `UPDATE dispatch_contexts + SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE id = ?` + ) + .run(dispatchId) + } + getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined { return this.findActiveDispatchForAssignee(handle) } @@ -717,6 +3341,10 @@ export class OrchestrationDb { return this.hasAnyDispatchContextsCache } + getActiveDispatchForIdentity(handle: string, paneKey?: string): DispatchContextRow | undefined { + return this.findActiveDispatchForAssignee(handle, paneKey) + } + private findActiveDispatchForAssignee( assigneeHandle: string, assigneePaneKey?: string @@ -759,7 +3387,7 @@ export class OrchestrationDb { completeDispatch(ctxId: string): void { this.db .prepare( - "UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now') WHERE id = ?" + "UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now'), capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) WHERE id = ?" ) .run(ctxId) } @@ -775,6 +3403,111 @@ export class OrchestrationDb { } } + settleWorkerReport(params: { + taskId: string + dispatchId: string + outcome: WorkerReportOutcome + result: string + }): WorkerReportSettlement { + this.db.exec('BEGIN IMMEDIATE') + try { + const settlement = this.settleWorkerReportInTransaction(params) + this.db.exec('COMMIT') + return settlement + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + private settleWorkerReportInTransaction(params: { + taskId: string + dispatchId: string + outcome: WorkerReportOutcome + result: string + }): WorkerReportSettlement { + const task = this.getTask(params.taskId) + if (!task) { + return { action: 'rejected', code: 'unknown_task', reason: `Unknown task ${params.taskId}.` } + } + const dispatch = this.getDispatchContextById(params.dispatchId) + if (!dispatch) { + return { + action: 'rejected', + code: 'unknown_dispatch', + reason: `Unknown dispatch ${params.dispatchId}.` + } + } + if (dispatch.task_id !== params.taskId) { + return { + action: 'rejected', + code: 'task_dispatch_mismatch', + reason: `Dispatch ${params.dispatchId} belongs to task ${dispatch.task_id}, not ${params.taskId}.` + } + } + + const expectedDispatchStatus = params.outcome === 'succeeded' ? 'completed' : 'failed' + const expectedTaskStatus = params.outcome === 'succeeded' ? 'completed' : 'failed' + if (dispatch.status === expectedDispatchStatus && task.status === expectedTaskStatus) { + return { action: 'settled', outcome: params.outcome, duplicate: true } + } + if (dispatch.status !== 'dispatched' || task.status !== 'dispatched') { + return { + action: 'rejected', + code: 'inactive_dispatch', + reason: `inactive dispatch ${params.dispatchId}: it or task ${params.taskId} is already settled.` + } + } + const latest = this.getDispatchContext(params.taskId) + if (latest?.id !== params.dispatchId) { + return { + action: 'rejected', + code: 'stale_dispatch', + reason: `Dispatch ${params.dispatchId} is not the current dispatch for task ${params.taskId}.` + } + } + + this.db.exec('SAVEPOINT settle_worker_report') + const dispatchUpdate = this.db + .prepare( + `UPDATE dispatch_contexts + SET status = ?, completed_at = datetime('now'), + last_failure = CASE WHEN ? = 'failed' THEN ? ELSE last_failure END, + capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE id = ? AND status = 'dispatched'` + ) + .run(expectedDispatchStatus, expectedDispatchStatus, params.result, params.dispatchId) + const taskUpdate = this.db + .prepare( + `UPDATE tasks + SET status = ?, result = ?, completed_at = datetime('now') + WHERE id = ? AND status = 'dispatched'` + ) + .run(expectedTaskStatus, params.result, params.taskId) + if (dispatchUpdate.changes !== 1 || taskUpdate.changes !== 1) { + this.db.exec('ROLLBACK TO settle_worker_report') + this.db.exec('RELEASE settle_worker_report') + return { + action: 'rejected', + code: 'inactive_dispatch', + reason: `Dispatch ${params.dispatchId} changed while its worker report was settling.` + } + } + this.db + .prepare( + `UPDATE worker_dispatches + SET state = ?, stage = 'settled', updated_at = datetime('now') + WHERE dispatch_id = ? AND state = 'ready'` + ) + .run(params.outcome === 'succeeded' ? 'succeeded' : 'failed', params.dispatchId) + this.closeQuestionsForDispatch(params.dispatchId) + if (params.outcome === 'succeeded') { + this.promoteReadyTasks(params.taskId) + } + this.db.exec('RELEASE settle_worker_report') + return { action: 'settled', outcome: params.outcome, duplicate: false } + } + failActiveDispatchForTask(taskId: string, error: string): DispatchContextRow | undefined { const active = this.db .prepare( @@ -819,7 +3552,10 @@ export class OrchestrationDb { this.db .prepare( - 'UPDATE dispatch_contexts SET status = ?, failure_count = ?, last_failure = ? WHERE id = ?' + `UPDATE dispatch_contexts + SET status = ?, failure_count = ?, last_failure = ?, + capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) + WHERE id = ?` ) .run(newStatus, newFailureCount, error, ctxId) @@ -838,8 +3574,16 @@ export class OrchestrationDb { const id = generateId('gate') const optionsJson = JSON.stringify(gate.options ?? []) this.db - .prepare('INSERT INTO decision_gates (id, task_id, question, options) VALUES (?, ?, ?, ?)') - .run(id, gate.taskId, gate.question, optionsJson) + .prepare( + 'INSERT INTO decision_gates (id, run_id, task_id, question, options) VALUES (?, ?, ?, ?, ?)' + ) + .run( + id, + this.getTask(gate.taskId)?.run_id ?? LEGACY_RUN_ID, + gate.taskId, + gate.question, + optionsJson + ) this.completeActiveDispatchForTask(gate.taskId) this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId) @@ -973,25 +3717,62 @@ export class OrchestrationDb { // ── Lifecycle ── + private runResetTransaction(statements: string): void { + this.db.exec('BEGIN IMMEDIATE') + try { + this.db.exec(statements) + this.db.exec('COMMIT') + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + resetAll(): void { - this.db.exec('DELETE FROM coordinator_runs') - this.db.exec('DELETE FROM decision_gates') - this.db.exec('DELETE FROM dispatch_contexts') - this.db.exec('DELETE FROM tasks') - this.db.exec('DELETE FROM messages') + // Why: retain mutation receipts so a lost reset response cannot replay as a new mutation. + this.runResetTransaction(` + DELETE FROM coordinator_runs; + DELETE FROM decision_gates; + DELETE FROM remote_questions; + DELETE FROM question_threads; + DELETE FROM deliveries; + DELETE FROM federation_relay_items; + DELETE FROM remote_dispatch_attachments; + DELETE FROM federated_dispatches; + DELETE FROM worker_dispatches; + DELETE FROM dispatch_contexts; + DELETE FROM tasks; + DELETE FROM messages; + DELETE FROM runs; + INSERT INTO runs (id, objective, home_database, consumer_generation, legacy) + VALUES ('${LEGACY_RUN_ID}', 'Legacy orchestration state (inspect only)', 'this_database', 0, 1); + `) this.hasAnyDispatchContextsCache = undefined } resetTasks(): void { - this.db.exec('DELETE FROM coordinator_runs') - this.db.exec('DELETE FROM decision_gates') - this.db.exec('DELETE FROM dispatch_contexts') - this.db.exec('DELETE FROM tasks') + this.runResetTransaction(` + DELETE FROM coordinator_runs; + DELETE FROM decision_gates; + DELETE FROM remote_questions; + DELETE FROM question_threads; + DELETE FROM federation_relay_items; + DELETE FROM remote_dispatch_attachments; + DELETE FROM federated_dispatches; + DELETE FROM worker_dispatches; + DELETE FROM dispatch_contexts; + DELETE FROM tasks; + `) this.hasAnyDispatchContextsCache = undefined } resetMessages(): void { - this.db.exec('DELETE FROM messages') + // Why: relay rows carry contiguous cross-server cursors, not just inbox history. + this.runResetTransaction(` + DELETE FROM question_threads; + DELETE FROM deliveries; + DELETE FROM messages; + `) } close(): void { diff --git a/src/main/runtime/orchestration/environment-transport.ts b/src/main/runtime/orchestration/environment-transport.ts new file mode 100644 index 00000000000..cb52c30d392 --- /dev/null +++ b/src/main/runtime/orchestration/environment-transport.ts @@ -0,0 +1,26 @@ +import { createHash } from 'node:crypto' +import type { + RuntimeOrchestrationEnvelope, + RuntimeRpcResponse +} from '../../../shared/runtime-rpc-envelope' + +export type OrchestrationWorkerServer = { + environmentId: string + name: string + peerFingerprint: string +} + +export type OrchestrationEnvironmentTransport = { + resolve(selector: string): OrchestrationWorkerServer + call( + selector: string, + method: string, + params: unknown, + timeoutMs?: number, + envelope?: RuntimeOrchestrationEnvelope + ): Promise> +} + +export function fingerprintOrchestrationPeer(publicKeyB64: string): string { + return createHash('sha256').update(Buffer.from(publicKeyB64, 'base64')).digest('base64url') +} diff --git a/src/main/runtime/orchestration/federation-control-message.ts b/src/main/runtime/orchestration/federation-control-message.ts new file mode 100644 index 00000000000..bcab04f99b8 --- /dev/null +++ b/src/main/runtime/orchestration/federation-control-message.ts @@ -0,0 +1,94 @@ +import { MESSAGE_TYPES, type MessagePriority, type MessageType } from './types' +import type { OrchestrationDb } from './db' +import { OrchestrationError } from './orchestration-error' + +const MESSAGE_TYPE_SET = new Set(MESSAGE_TYPES) + +export type FederatedControlMessage = { + from: string + subject: string + body: string + type: MessageType + priority: MessagePriority + threadId: string | null + payload: string | null +} + +export function encodeFederatedControlMessage(message: FederatedControlMessage): string { + return JSON.stringify(message) +} + +export function parseFederatedControlMessage(payload: string): FederatedControlMessage { + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + throw new OrchestrationError('invalid_argument', 'Federated control message is invalid JSON.') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new OrchestrationError('invalid_argument', 'Federated control message is invalid.') + } + const message = parsed as Partial + if ( + typeof message.from !== 'string' || + typeof message.subject !== 'string' || + typeof message.body !== 'string' || + typeof message.type !== 'string' || + !MESSAGE_TYPE_SET.has(message.type as MessageType) + ) { + throw new OrchestrationError('invalid_argument', 'Federated control message is incomplete.') + } + return { + from: message.from, + subject: message.subject, + body: message.body, + type: message.type as MessageType, + priority: + message.priority === 'high' || message.priority === 'urgent' ? message.priority : 'normal', + threadId: typeof message.threadId === 'string' ? message.threadId : null, + payload: typeof message.payload === 'string' ? message.payload : null + } +} + +export function importFederatedControlMessage( + db: OrchestrationDb, + params: { + dispatchId: string + messageId: string + payload: string + } +): { imported: boolean; type: MessageType } { + const message = parseFederatedControlMessage(params.payload) + const recipient = `dispatch:${params.dispatchId}` + const existing = db.getMessageById(params.messageId) + if (existing) { + if ( + existing.to_handle !== recipient || + existing.from_handle !== message.from || + existing.subject !== message.subject || + existing.body !== message.body || + existing.type !== message.type || + existing.priority !== message.priority || + existing.thread_id !== message.threadId || + existing.payload !== message.payload + ) { + throw new OrchestrationError( + 'request_mismatch', + `Federated control message ${params.messageId} conflicts with an existing message.` + ) + } + return { imported: false, type: message.type } + } + db.insertMessage({ + id: params.messageId, + from: message.from, + to: recipient, + subject: message.subject, + body: message.body, + type: message.type, + priority: message.priority, + threadId: message.threadId ?? undefined, + payload: message.payload ?? undefined + }) + return { imported: true, type: message.type } +} diff --git a/src/main/runtime/orchestration/federation-sync.test.ts b/src/main/runtime/orchestration/federation-sync.test.ts new file mode 100644 index 00000000000..339b1a55f27 --- /dev/null +++ b/src/main/runtime/orchestration/federation-sync.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { parseRelayedMessage } from './federation-sync' + +describe('federation relay parsing', () => { + it('accepts a supported message type', () => { + expect( + parseRelayedMessage( + JSON.stringify({ subject: 'done', body: 'Finished', type: 'worker_done' }) + ) + ).toMatchObject({ type: 'worker_done', priority: 'normal' }) + }) + + it('rejects an unsupported type before it reaches the database constraint', () => { + expect(() => + parseRelayedMessage(JSON.stringify({ subject: 'bad', body: 'Blocked', type: 'invented' })) + ).toThrowError('Federated relay message type invented is not supported.') + }) +}) diff --git a/src/main/runtime/orchestration/federation-sync.ts b/src/main/runtime/orchestration/federation-sync.ts new file mode 100644 index 00000000000..85856883aa9 --- /dev/null +++ b/src/main/runtime/orchestration/federation-sync.ts @@ -0,0 +1,253 @@ +import { + MESSAGE_TYPES, + type MessagePriority, + type MessageType, + type WorkerReportOutcome +} from './types' +import type { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationError } from './orchestration-error' + +const MESSAGE_TYPE_SET = new Set(MESSAGE_TYPES) + +function isMessageType(value: unknown): value is MessageType { + return typeof value === 'string' && MESSAGE_TYPE_SET.has(value as MessageType) +} + +type PulledRelayItem = { + dispatch_id: string + direction: 'to_home' + sequence: number + message_id: string + kind: string + payload: string +} + +type RelayedMessage = { + from: string + subject: string + body: string + type: MessageType + priority: MessagePriority + threadId: string | null + payload: string | null +} + +export async function syncFederatedDispatch( + runtime: OrcaRuntimeService, + dispatchId: string +): Promise<{ imported: number; acknowledgedThrough: number }> { + const db = runtime.getOrchestrationDb() + const federated = db.getFederatedDispatch(dispatchId) + const dispatch = db.getDispatchContextById(dispatchId) + if (!federated || !dispatch) { + throw new OrchestrationError( + 'dispatch_not_found', + `Federated Dispatch ${dispatchId} was not found.` + ) + } + const currentServer = runtime.resolveOrchestrationWorkerServer(federated.environment_id) + if (currentServer.peerFingerprint !== federated.peer_fingerprint) { + throw new OrchestrationError( + 'peer_changed', + `Saved environment ${federated.environment_name} now identifies a different Orca server.` + ) + } + + const pulled = (await runtime.callOrchestrationWorkerServer( + federated.environment_id, + 'orchestration.federationPull', + { + dispatchId, + afterSequence: federated.to_home_imported_sequence, + limit: 50 + }, + 15_000 + )) as { runtimeEpoch: string; items: PulledRelayItem[] } + let cursor = federated.to_home_imported_sequence + let imported = 0 + for (const item of pulled.items) { + if (item.dispatch_id !== dispatchId || item.sequence !== cursor + 1) { + throw new OrchestrationError( + 'operation_unknown', + `Federated relay for ${dispatchId} is not contiguous after sequence ${cursor}.` + ) + } + const message = parseRelayedMessage(item.payload) + const stored = db.importFederatedRelayItem({ + dispatchId, + sequence: item.sequence, + message: { + id: item.message_id, + runId: dispatch.run_id, + from: `dispatch:${dispatchId}`, + to: `run:${dispatch.run_id}`, + subject: message.subject, + body: message.body, + type: message.type, + priority: message.priority, + threadId: message.threadId ?? undefined, + payload: message.payload ?? undefined + }, + lifecycle: parseFederatedLifecycle(message, item.message_id, dispatchId, dispatch.task_id) + }) + cursor = item.sequence + runtime.notifyMessageArrived(stored.message.to_handle, stored.message.type) + imported += stored.duplicate ? 0 : 1 + } + + if (cursor > 0) { + await runtime.callOrchestrationWorkerServer( + federated.environment_id, + 'orchestration.federationAck', + { dispatchId, throughSequence: cursor }, + 15_000, + { orchestrationRequestId: `relay_ack_${dispatchId}_${cursor}` } + ) + } + const toWorker = + db.getWorkerDispatch(dispatchId)?.state === 'ready' + ? db.listPendingFederationRelay(dispatchId, 'to_worker') + : [] + if (toWorker.length > 0) { + const delivered = (await runtime.callOrchestrationWorkerServer( + federated.environment_id, + 'orchestration.federationImport', + { dispatchId, items: toWorker }, + 15_000, + { + orchestrationRequestId: `relay_import_${dispatchId}_${toWorker.at(-1)?.sequence ?? 0}` + } + )) as { acknowledgedThrough: number } + db.acknowledgeFederationRelay({ + dispatchId, + direction: 'to_worker', + throughSequence: delivered.acknowledgedThrough + }) + } + return { imported, acknowledgedThrough: cursor } +} + +export function parseRelayedMessage(payload: string): RelayedMessage { + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + throw new OrchestrationError('invalid_argument', 'Federated relay payload is invalid JSON.') + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new OrchestrationError('invalid_argument', 'Federated relay payload is not a message.') + } + const message = parsed as Partial + if (typeof message.subject !== 'string' || typeof message.body !== 'string') { + throw new OrchestrationError('invalid_argument', 'Federated relay message is incomplete.') + } + if (!isMessageType(message.type)) { + throw new OrchestrationError( + 'invalid_argument', + `Federated relay message type ${String(message.type)} is not supported.` + ) + } + return { + from: typeof message.from === 'string' ? message.from : 'remote-worker', + subject: message.subject, + body: message.body, + type: message.type, + priority: + message.priority === 'high' || message.priority === 'urgent' ? message.priority : 'normal', + threadId: typeof message.threadId === 'string' ? message.threadId : null, + payload: typeof message.payload === 'string' ? message.payload : null + } +} + +function parseFederatedLifecycle( + message: RelayedMessage, + messageId: string, + dispatchId: string, + taskId: string +): + | { kind: 'none' } + | { kind: 'heartbeat'; at: string } + | { + kind: 'worker_report' + taskId: string + outcome: WorkerReportOutcome + result: string + } + | { kind: 'rejected'; code: string; reason: string } { + if (message.type === 'heartbeat') { + return { kind: 'heartbeat', at: new Date().toISOString() } + } + if (message.type !== 'worker_done') { + return { kind: 'none' } + } + let payload + try { + payload = parseWorkerReportPayload(message.payload) + } catch (error) { + return { + kind: 'rejected', + code: 'invalid_payload', + reason: error instanceof Error ? error.message : String(error) + } + } + if (payload.dispatchId !== dispatchId || payload.taskId !== taskId) { + return { + kind: 'rejected', + code: 'task_dispatch_mismatch', + reason: `Federated report does not match Dispatch ${dispatchId}.` + } + } + const result = JSON.stringify({ + provenance: 'worker_report', + outcome: payload.outcome, + messageId, + reportedBy: `dispatch:${dispatchId}`, + subject: message.subject, + body: message.body, + completedBy: `dispatch:${dispatchId}`, + filesModified: payload.filesModified, + reportPath: payload.reportPath, + completedAt: new Date().toISOString() + }) + return { + kind: 'worker_report', + taskId: payload.taskId, + outcome: payload.outcome, + result + } +} + +function parseWorkerReportPayload(payload: string | null): { + taskId: string + dispatchId: string + outcome: WorkerReportOutcome + filesModified: string[] + reportPath: string | null +} { + let parsed: unknown + try { + parsed = payload ? JSON.parse(payload) : null + } catch { + parsed = null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new OrchestrationError('invalid_argument', 'Federated worker report is invalid.') + } + const report = parsed as Record + if ( + typeof report.taskId !== 'string' || + typeof report.dispatchId !== 'string' || + (report.outcome !== 'succeeded' && report.outcome !== 'failed') + ) { + throw new OrchestrationError('invalid_argument', 'Federated worker report is incomplete.') + } + return { + taskId: report.taskId, + dispatchId: report.dispatchId, + outcome: report.outcome, + filesModified: Array.isArray(report.filesModified) + ? report.filesModified.filter((file): file is string => typeof file === 'string') + : [], + reportPath: typeof report.reportPath === 'string' ? report.reportPath : null + } +} diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts index ea8ae94bc19..de71c21ded5 100644 --- a/src/main/runtime/orchestration/formatter.test.ts +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -5,6 +5,7 @@ import type { MessageRow } from './types' function makeMessage(overrides: Partial = {}): MessageRow { return { id: 'msg_test1', + run_id: 'run_test', from_handle: 'term_abc123', to_handle: 'term_coord', subject: 'Auth API implementation complete', diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts index 8fed046d421..3a916751533 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.test.ts @@ -17,7 +17,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) }) expect(reconcileLifecycleMessage(db, message, (line) => logs.push(line))).toMatchObject({ @@ -43,7 +43,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }), senderPaneKey: `tab_w:${LEAF_A}` }) @@ -51,6 +51,94 @@ describe('lifecycle reconciliation', () => { expect(db.getTask(task.id)?.status).toBe('completed') }) + it('fails both the dispatch and task from an authenticated failed worker report', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Failed: tests cannot start', + body: 'I attempted the work. The required service is unavailable. No files changed.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'failed', + filesModified: [] + }), + senderPaneKey: `tab_w:${LEAF_A}` + }) + + expect(reconcileLifecycleMessage(db, message)).toEqual({ + action: 'failed', + taskId: task.id, + dispatchId: dispatch.id + }) + expect(db.getTask(task.id)).toMatchObject({ status: 'failed' }) + expect(db.getDispatchContextById(dispatch.id)).toMatchObject({ status: 'failed' }) + expect(JSON.parse(db.getTask(task.id)?.result ?? '{}')).toMatchObject({ + provenance: 'worker_report', + outcome: 'failed', + messageId: message.id + }) + }) + + it('replays an identical terminal outcome without mutating settled state', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + const makeMessage = () => + db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + }) + + expect(reconcileLifecycleMessage(db, makeMessage()).action).toBe('completed') + const result = db.getTask(task.id)?.result + expect(reconcileLifecycleMessage(db, makeMessage()).action).toBe('completed') + expect(db.getTask(task.id)?.result).toBe(result) + }) + + it.each([ + { payload: undefined, code: 'invalid_payload' }, + { payload: '{', code: 'invalid_payload' }, + { + payload: JSON.stringify({ dispatchId: 'ctx_1', outcome: 'succeeded' }), + code: 'missing_task_id' + }, + { + payload: JSON.stringify({ taskId: 'task_1', outcome: 'succeeded' }), + code: 'missing_dispatch_id' + }, + { + payload: JSON.stringify({ taskId: 'task_1', dispatchId: 'ctx_1', outcome: 'maybe' }), + code: 'invalid_outcome' + } + ])('rejects malformed worker reports with $code', ({ payload, code }) => { + db = new OrchestrationDb(':memory:') + const message = db.insertMessage({ + from: 'term_worker', + to: 'term_coordinator', + subject: 'Done', + type: 'worker_done', + payload + }) + + expect(reconcileLifecycleMessage(db, message)).toMatchObject({ action: 'rejected', code }) + expect(db.getMessageById(message.id)).toMatchObject({ + priority: 'high', + subject: 'Rejected worker_done: Done' + }) + }) + it('completes worker_done from the same leaf after a pane break-out changed the tab half', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) @@ -62,7 +150,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }), senderPaneKey: `tab_old:${LEAF_A}` }) @@ -79,7 +167,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }), senderPaneKey: 'tab_w:42' }) @@ -96,7 +184,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }), senderPaneKey: `tab_w2:${LEAF_B}` }) @@ -142,6 +230,7 @@ describe('lifecycle reconciliation', () => { payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, + outcome: 'succeeded', _orcaLifecycleRejection: { code: 'sender_not_assignee', reason: 'caller supplied' @@ -167,7 +256,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) }) expect(reconcileLifecycleMessage(db, message)).toMatchObject({ @@ -186,7 +275,11 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: acceptedTask.id, dispatchId: acceptedDispatch.id }) + payload: JSON.stringify({ + taskId: acceptedTask.id, + dispatchId: acceptedDispatch.id, + outcome: 'succeeded' + }) }) expect(reconcileLifecycleMessage(db, accepted).action).toBe('completed') @@ -197,7 +290,11 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: rejectedTask.id, dispatchId: rejectedDispatch.id }) + payload: JSON.stringify({ + taskId: rejectedTask.id, + dispatchId: rejectedDispatch.id, + outcome: 'succeeded' + }) }) expect(reconcileLifecycleMessage(db, rejected)).toMatchObject({ action: 'rejected', @@ -211,7 +308,11 @@ describe('lifecycle reconciliation', () => { const parent = db.createTask({ spec: 'parent' }) const child = db.createTask({ spec: 'child', deps: [parent.id] }) const dispatch = db.createDispatchContext(parent.id, 'term_worker', `tab_w:${LEAF_A}`) - const payload = JSON.stringify({ taskId: parent.id, dispatchId: dispatch.id }) + const payload = JSON.stringify({ + taskId: parent.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) const foreign = db.insertMessage({ from: 'term_coordinator', @@ -243,7 +344,11 @@ describe('lifecycle reconciliation', () => { db = new OrchestrationDb(':memory:') const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_worker', `tab_w:${LEAF_A}`) - const payload = JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + const payload = JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) const owner = db.insertMessage({ from: 'term_worker', to: 'term_coordinator', @@ -280,7 +385,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }), senderPaneKey: `tab_w2:${LEAF_B}` }) @@ -390,7 +495,7 @@ describe('lifecycle reconciliation', () => { to: 'term_coordinator', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) }) reconcileLifecycleMessage(db, done) diff --git a/src/main/runtime/orchestration/lifecycle-reconciliation.ts b/src/main/runtime/orchestration/lifecycle-reconciliation.ts index 96917babaf6..3d2793413b7 100644 --- a/src/main/runtime/orchestration/lifecycle-reconciliation.ts +++ b/src/main/runtime/orchestration/lifecycle-reconciliation.ts @@ -1,5 +1,5 @@ import type { OrchestrationDb } from './db' -import type { MessageRow } from './types' +import type { MessageRow, WorkerReportOutcome } from './types' import { parsePaneKey } from '../../../shared/stable-pane-id' // Why: the tab half can change on pane break-out, while opaque legacy keys @@ -35,11 +35,25 @@ export type LifecycleReconciliationResult = | { action: 'suppressed' } | LifecycleRejectionResult | { action: 'completed'; taskId: string; dispatchId: string } + | { action: 'failed'; taskId: string; dispatchId: string } | { action: 'heartbeat_recorded'; dispatchId: string } +export type LifecycleRejectionCode = + | 'sender_not_assignee' + | 'dispatch_capability_invalid' + | 'invalid_payload' + | 'missing_task_id' + | 'missing_dispatch_id' + | 'invalid_outcome' + | 'unknown_task' + | 'unknown_dispatch' + | 'task_dispatch_mismatch' + | 'inactive_dispatch' + | 'stale_dispatch' + export type LifecycleRejectionResult = { action: 'rejected' - code: 'sender_not_assignee' + code: LifecycleRejectionCode reason: string } @@ -54,7 +68,11 @@ function parseObjectPayload(msg: MessageRow, onInvalidJson: () => void): Record< try { const parsed: unknown = JSON.parse(msg.payload) - return parsed && typeof parsed === 'object' ? (parsed as Record) : {} + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + onInvalidJson() + return {} } catch { onInvalidJson() return {} @@ -68,7 +86,7 @@ function getPersistedLifecycleRejection( if ( !rejection || typeof rejection !== 'object' || - (rejection as { code?: unknown }).code !== 'sender_not_assignee' || + typeof (rejection as { code?: unknown }).code !== 'string' || typeof (rejection as { reason?: unknown }).reason !== 'string' ) { return undefined @@ -77,7 +95,7 @@ function getPersistedLifecycleRejection( // also prevents caller-supplied markers from turning lifecycle sends into success. return { action: 'rejected', - code: 'sender_not_assignee', + code: (rejection as { code: LifecycleRejectionCode }).code, reason: (rejection as { reason: string }).reason } } @@ -98,6 +116,7 @@ export function reconcileLifecycleMessage( case 'escalation': case 'handoff': case 'decision_gate': + case 'question': return { action: 'ignored' } } } @@ -142,7 +161,7 @@ function reconcileHeartbeatMessage( // a hung assignee behind another agent's timer. const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) onLog(`Heartbeat rejected: ${reason}`) - db.convertLifecycleMessageToRejection(msg.id, reason) + db.convertLifecycleMessageToRejection(msg.id, 'sender_not_assignee', reason) return { action: 'rejected', code: 'sender_not_assignee', reason } } @@ -159,7 +178,9 @@ function reconcileWorkerDoneMessage( ): LifecycleReconciliationResult { onLog(`Worker done: ${msg.from_handle} — ${msg.subject}`) + let invalidPayload = false const payload = parseObjectPayload(msg, () => { + invalidPayload = true onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`) }) const persistedRejection = getPersistedLifecycleRejection(payload) @@ -169,58 +190,83 @@ function reconcileWorkerDoneMessage( onLog(`Warning: worker_done rejected: ${persistedRejection.reason}`) return persistedRejection } + if (invalidPayload || !msg.payload) { + return rejectLifecycleMessage( + db, + msg, + 'invalid_payload', + 'worker_done requires a JSON object payload.', + onLog + ) + } const taskId = payload.taskId if (typeof taskId !== 'string' || taskId.length === 0) { - onLog(`Warning: worker_done without taskId from ${msg.from_handle}`) - return { action: 'ignored' } + return rejectLifecycleMessage(db, msg, 'missing_task_id', 'worker_done requires taskId.', onLog) } const dispatchId = payload.dispatchId if (typeof dispatchId !== 'string' || dispatchId.length === 0) { - onLog(`Warning: worker_done without dispatchId from ${msg.from_handle}`) - return { action: 'ignored' } + return rejectLifecycleMessage( + db, + msg, + 'missing_dispatch_id', + 'worker_done requires dispatchId.', + onLog + ) + } + + const outcome = payload.outcome + if (outcome !== 'succeeded' && outcome !== 'failed') { + return rejectLifecycleMessage( + db, + msg, + 'invalid_outcome', + 'worker_done requires outcome=succeeded or outcome=failed.', + onLog + ) } const task = db.getTask(taskId) if (!task) { - onLog(`Warning: worker_done for unknown task ${taskId}`) - return { action: 'ignored' } + return rejectLifecycleMessage( + db, + msg, + 'unknown_task', + `worker_done references unknown task ${taskId}.`, + onLog + ) } // Why: taskId alone is not a completion authority; retried tasks can have // stale worker_done messages racing the current active dispatch. const dispatch = db.getDispatchContextById(dispatchId) if (!dispatch) { - onLog(`Warning: worker_done for unknown dispatch ${dispatchId}`) - return { action: 'ignored' } + return rejectLifecycleMessage( + db, + msg, + 'unknown_dispatch', + `worker_done references unknown dispatch ${dispatchId}.`, + onLog + ) } if (dispatch.task_id !== taskId) { - onLog( - `Warning: worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}` + return rejectLifecycleMessage( + db, + msg, + 'task_dispatch_mismatch', + `worker_done dispatch ${dispatchId} belongs to ${dispatch.task_id}, not ${taskId}.`, + onLog ) - return { action: 'ignored' } } if (!hasLifecycleAuthority(dispatch, msg)) { const reason = buildLifecycleAuthorityRejectionReason(dispatchId, dispatch, msg) onLog(`Warning: worker_done rejected: ${reason}`) - db.convertLifecycleMessageToRejection(msg.id, reason) + db.convertLifecycleMessageToRejection(msg.id, 'sender_not_assignee', reason) return { action: 'rejected', code: 'sender_not_assignee', reason } } // Why: `orchestration.send` can release the DB lock before waking the // coordinator; the later coordinator read still needs to observe completion. - if (dispatch.status === 'completed' && task.status === 'completed') { - return { action: 'completed', taskId, dispatchId } - } - if (dispatch.status !== 'dispatched') { - onLog(`Warning: worker_done for inactive dispatch ${dispatchId} ignored`) - return { action: 'ignored' } - } - if (db.getDispatchContext(taskId)?.id !== dispatchId || task.status !== 'dispatched') { - onLog(`Warning: worker_done for stale dispatch ${dispatchId} ignored`) - return { action: 'ignored' } - } - const filesModified = Array.isArray(payload.filesModified) && payload.filesModified.every((file) => typeof file === 'string') @@ -228,17 +274,48 @@ function reconcileWorkerDoneMessage( : [] const result = JSON.stringify({ + provenance: 'worker_report', + outcome, + messageId: msg.id, + reportedBy: msg.from_handle, + subject: msg.subject, + body: msg.body, completedBy: msg.from_handle, filesModified, + reportPath: typeof payload.reportPath === 'string' ? payload.reportPath : null, completedAt: new Date().toISOString() }) - db.updateTaskStatus(taskId, 'completed', result) + const settlement = db.settleWorkerReport({ + taskId, + dispatchId, + outcome: outcome as WorkerReportOutcome, + result + }) + if (settlement.action === 'rejected') { + return rejectLifecycleMessage(db, msg, settlement.code, settlement.reason, onLog) + } suppressEarlierHeartbeats(db, msg, dispatchId) - onLog(`Task ${taskId} completed`) + if (outcome === 'failed') { + onLog(`Task ${taskId} failed by worker report`) + return { action: 'failed', taskId, dispatchId } + } + onLog(`Task ${taskId} completed by worker report`) return { action: 'completed', taskId, dispatchId } } +function rejectLifecycleMessage( + db: OrchestrationDb, + msg: MessageRow, + code: LifecycleRejectionCode, + reason: string, + onLog: LogFn +): LifecycleRejectionResult { + onLog(`Warning: ${msg.type} rejected: ${reason}`) + db.convertLifecycleMessageToRejection(msg.id, code, reason) + return { action: 'rejected', code, reason } +} + function buildLifecycleAuthorityRejectionReason( dispatchId: string, dispatch: { assignee_handle: string | null; assignee_pane_key: string | null }, diff --git a/src/main/runtime/orchestration/orchestration-db-permissions.test.ts b/src/main/runtime/orchestration/orchestration-db-permissions.test.ts new file mode 100644 index 00000000000..07285ef35b1 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-db-permissions.test.ts @@ -0,0 +1,27 @@ +import { mkdtempSync, rmSync, statSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +describe.skipIf(process.platform === 'win32')('orchestration database permissions', () => { + let directory: string | undefined + let db: OrchestrationDb | undefined + + afterEach(() => { + db?.close() + if (directory) { + rmSync(directory, { recursive: true, force: true }) + } + }) + + it('restricts the database and live SQLite sidecars to the current user', () => { + directory = mkdtempSync(join(tmpdir(), 'orca-orchestration-permissions-')) + const dbPath = join(directory, 'orchestration.db') + db = new OrchestrationDb(dbPath) + + for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) { + expect(statSync(path).mode & 0o777).toBe(0o600) + } + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-error.ts b/src/main/runtime/orchestration/orchestration-error.ts new file mode 100644 index 00000000000..41b5e88102e --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-error.ts @@ -0,0 +1,11 @@ +export class OrchestrationError extends Error { + readonly code: string + readonly data?: unknown + + constructor(code: string, message: string, data?: unknown) { + super(message) + this.name = 'OrchestrationError' + this.code = code + this.data = data + } +} diff --git a/src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts b/src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts new file mode 100644 index 00000000000..a6039c381f9 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-mutation-question-db.test.ts @@ -0,0 +1,167 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +describe('OrchestrationDb mutation and question state', () => { + let db: OrchestrationDb | undefined + + afterEach(() => { + db?.close() + }) + + function createDb(): OrchestrationDb { + db = new OrchestrationDb(':memory:') + return db + } + + describe('durable mutation receipts', () => { + it('replays completed input and rejects request ID reuse with changed input', () => { + const d = createDb() + const started = d.beginMutationReceipt({ + callerFingerprint: 'caller_a', + requestId: 'request_1', + method: 'orchestration.send', + payloadHash: 'hash_a' + }) + expect(started.disposition).toBe('started') + + d.completeMutationReceipt({ + callerFingerprint: 'caller_a', + requestId: 'request_1', + method: 'orchestration.send', + payloadHash: 'hash_a', + receipt: '{"messageId":"msg_1"}' + }) + expect( + d.beginMutationReceipt({ + callerFingerprint: 'caller_a', + requestId: 'request_1', + method: 'orchestration.send', + payloadHash: 'hash_a' + }) + ).toMatchObject({ + disposition: 'completed', + row: { receipt: '{"messageId":"msg_1"}' } + }) + + expect(() => + d.beginMutationReceipt({ + callerFingerprint: 'caller_a', + requestId: 'request_1', + method: 'orchestration.send', + payloadHash: 'hash_b' + }) + ).toThrow('already used with different input') + }) + + it('keeps caller namespaces separate and can discard only pending work', () => { + const d = createDb() + for (const callerFingerprint of ['caller_a', 'caller_b']) { + d.beginMutationReceipt({ + callerFingerprint, + requestId: 'same_request', + method: 'orchestration.send', + payloadHash: 'same_hash' + }) + } + expect(d.getMutationReceipt('caller_a', 'same_request')?.state).toBe('pending') + expect(d.getMutationReceipt('caller_b', 'same_request')?.state).toBe('pending') + + d.discardPendingMutationReceipt('caller_a', 'same_request') + expect(d.getMutationReceipt('caller_a', 'same_request')).toBeUndefined() + expect(d.getMutationReceipt('caller_b', 'same_request')?.state).toBe('pending') + }) + }) + + describe('question threads', () => { + it('accepts a question message in the fresh canonical schema', () => { + const d = createDb() + const message = d.insertMessage({ + from: 'worker', + to: 'run:run_1', + subject: 'Need input', + type: 'question' + }) + + expect(message.type).toBe('question') + }) + + it('uses the original message ID and records one durable answer', () => { + const d = createDb() + const run = d.createRun({ + objective: 'Questions', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' + }) + const task = d.createTask({ spec: 'ask', runId: run.id }) + const dispatch = d.createDispatchContext(task.id, 'term_worker') + const created = d.createQuestion({ + runId: run.id, + dispatchId: dispatch.id, + askerHandle: 'term_worker', + question: 'Which format?', + options: ['old', 'new'] + }) + + expect(created.question.message_id).toBe(created.message.id) + expect(created.message).toMatchObject({ + run_id: run.id, + to_handle: `run:${run.id}`, + type: 'question', + thread_id: created.message.id + }) + const answer = d.answerQuestion({ + messageId: created.message.id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: 'old' + }) + const replay = d.answerQuestion({ + messageId: created.message.id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: 'old' + }) + + expect(answer.message.to_handle).toBe(`dispatch:${dispatch.id}`) + expect(answer.question.status).toBe('answered') + expect(replay.message.id).toBe(answer.message.id) + expect(replay.duplicate).toBe(true) + expect(() => + d.answerQuestion({ + messageId: created.message.id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: 'new' + }) + ).toThrow(/different answer/) + }) + + it('closes pending questions with their Dispatch', () => { + const d = createDb() + const run = d.createRun({ + objective: 'Close questions', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' + }) + const task = d.createTask({ spec: 'ask', runId: run.id }) + const dispatch = d.createDispatchContext(task.id, 'term_worker') + const created = d.createQuestion({ + runId: run.id, + dispatchId: dispatch.id, + askerHandle: 'term_worker', + question: 'Still active?' + }) + + expect(d.closeQuestionsForDispatch(dispatch.id)).toEqual([created.message.id]) + expect(d.getQuestion(created.message.id)?.status).toBe('closed') + expect(() => + d.answerQuestion({ + messageId: created.message.id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: 'late' + }) + ).toThrow(/inactive/) + }) + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-reset-db.test.ts b/src/main/runtime/orchestration/orchestration-reset-db.test.ts new file mode 100644 index 00000000000..e98ec4230b5 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-reset-db.test.ts @@ -0,0 +1,101 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { LEGACY_RUN_ID, OrchestrationDb } from './db' + +describe('OrchestrationDb reset scopes', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + function createState() { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'Reset contract', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' + }) + const task = db.createTask({ spec: 'work', runId: run.id }) + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: { worktree: 'current' }, + runtimeEpoch: 'runtime_1', + federation: { + environmentId: 'environment_1', + environmentName: 'Windows', + peerFingerprint: 'peer_1', + protocolVersion: 1 + }, + mutationReceipt: { + callerFingerprint: 'caller_1', + requestId: 'request_1', + method: 'orchestration.workerStart', + payloadHash: 'hash_1' + } + }) + const message = db.insertMessage({ + runId: run.id, + from: 'worker', + to: `run:${run.id}`, + subject: 'status' + }) + db.enqueueFederationRelay({ + dispatchId: started.dispatch.id, + direction: 'to_home', + kind: 'question', + payload: '{}', + messageId: 'question_1', + remoteQuestion: true + }) + return { run, task, started, message } + } + + it('resetAll clears Runs, worker/federation state, and messages', () => { + const state = createState() + + db!.resetAll() + + expect(db!.listRuns()).toEqual([expect.objectContaining({ id: LEGACY_RUN_ID, legacy: 1 })]) + expect(db!.getTask(state.task.id)).toBeUndefined() + expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined() + expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined() + // The ledger survives so a lost reset response cannot replay as a new mutation. + expect(db!.getMutationReceipt('caller_1', 'request_1')).toBeDefined() + expect(db!.getInbox()).toEqual([]) + expect( + db!.listFederationRelay({ + dispatchId: state.started.dispatch.id, + direction: 'to_home', + afterSequence: 0 + }) + ).toEqual([]) + }) + + it('resetTasks preserves Runs and messages while clearing every worker attachment', () => { + const state = createState() + + db!.resetTasks() + + expect(db!.getRun(state.run.id)).toBeDefined() + expect(db!.getMessageById(state.message.id)).toBeDefined() + expect(db!.getTask(state.task.id)).toBeUndefined() + expect(db!.getWorkerDispatch(state.started.dispatch.id)).toBeUndefined() + expect(db!.getFederatedDispatch(state.started.dispatch.id)).toBeUndefined() + expect(db!.getRemoteQuestion('question_1')).toBeUndefined() + }) + + it('resetMessages preserves active relay cursors while clearing the Run inbox', () => { + const state = createState() + + db!.resetMessages() + + expect(db!.getTask(state.task.id)).toBeDefined() + expect(db!.getInbox()).toEqual([]) + expect(db!.getRemoteQuestion('question_1')).toBeDefined() + expect( + db!.listFederationRelay({ + dispatchId: state.started.dispatch.id, + direction: 'to_home', + afterSequence: 0 + }) + ).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts b/src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts new file mode 100644 index 00000000000..acd6e936f47 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts @@ -0,0 +1,279 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { LEGACY_RUN_ID, OrchestrationDb } from './db' + +describe('OrchestrationDb Run state', () => { + let db: OrchestrationDb | undefined + + afterEach(() => { + db?.close() + }) + + function createDb(): OrchestrationDb { + db = new OrchestrationDb(':memory:') + return db + } + + function createBoundRun(d: OrchestrationDb) { + return d.createRun({ + objective: 'Mailbox test', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' + }) + } + + describe('Run deliveries', () => { + it('returns one bounded FIFO batch and replays it until acknowledgment', () => { + const d = createDb() + const run = createBoundRun(d) + for (let index = 0; index < 55; index++) { + d.insertMessage({ + from: 'worker', + to: `run:${run.id}`, + subject: `message ${index}`, + runId: run.id + }) + } + + const first = d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + }) + const replay = d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + }) + + expect(first?.messages).toHaveLength(50) + expect(first?.messages[0].subject).toBe('message 0') + expect(first?.messages[49].subject).toBe('message 49') + expect(replay?.delivery.id).toBe(first?.delivery.id) + expect(replay?.replayed).toBe(true) + + d.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation, + deliveryId: first!.delivery.id + }) + const next = d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + }) + expect(next?.messages.map((message) => message.subject)).toEqual([ + 'message 50', + 'message 51', + 'message 52', + 'message 53', + 'message 54' + ]) + }) + + it('acknowledges the whole batch idempotently without consuming newer mail', () => { + const d = createDb() + const run = createBoundRun(d) + d.insertMessage({ from: 'a', to: `run:${run.id}`, subject: 'first', runId: run.id }) + const delivery = d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + })! + d.insertMessage({ from: 'b', to: `run:${run.id}`, subject: 'newer', runId: run.id }) + + const firstAck = d.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation, + deliveryId: delivery.delivery.id + }) + const duplicateAck = d.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation, + deliveryId: delivery.delivery.id + }) + + expect(firstAck.duplicate).toBe(false) + expect(duplicateAck.duplicate).toBe(true) + expect( + d + .getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + }) + ?.messages.map((message) => message.subject) + ).toEqual(['newer']) + }) + + it('uses type filters only as wake predicates and returns the full oldest batch', () => { + const d = createDb() + const run = createBoundRun(d) + d.insertMessage({ from: 'a', to: `run:${run.id}`, subject: 'status', runId: run.id }) + expect( + d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation, + wakeTypes: ['worker_done'] + }) + ).toBeUndefined() + d.insertMessage({ + from: 'b', + to: `run:${run.id}`, + subject: 'done', + type: 'worker_done', + runId: run.id + }) + + const delivery = d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation, + wakeTypes: ['worker_done'] + }) + expect(delivery?.messages.map((message) => message.subject)).toEqual(['status', 'done']) + }) + + it('fences an outstanding batch when the Run consumer changes', () => { + const d = createDb() + const run = createBoundRun(d) + d.insertMessage({ from: 'a', to: `run:${run.id}`, subject: 'one', runId: run.id }) + const oldDelivery = d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + })! + const rebound = d.bindRun({ + runId: run.id, + coordinatorHandle: 'term_new', + coordinatorPaneKey: 'tab_new:22222222-2222-4222-9222-222222222222' + })! + + let fencedError: unknown + try { + d.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation, + deliveryId: oldDelivery.delivery.id + }) + } catch (error) { + fencedError = error + } + expect(fencedError).toMatchObject({ code: 'consumer_fenced' }) + const replacement = d.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: rebound.consumer_generation + }) + expect(replacement?.delivery.id).not.toBe(oldDelivery.delivery.id) + expect(replacement?.messages.map((message) => message.subject)).toEqual(['one']) + }) + + it('replays an outstanding batch after reopening the database', () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-delivery-')) + const dbPath = join(dir, 'orchestration.db') + try { + const firstDb = new OrchestrationDb(dbPath) + const run = createBoundRun(firstDb) + firstDb.insertMessage({ + from: 'a', + to: `run:${run.id}`, + subject: 'survives', + runId: run.id + }) + const first = firstDb.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + })! + firstDb.close() + + const reopened = new OrchestrationDb(dbPath) + db = reopened + const replay = reopened.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: run.consumer_generation + }) + expect(replay?.delivery.id).toBe(first.delivery.id) + expect(replay?.messages[0].subject).toBe('survives') + } finally { + db?.close() + db = undefined + rmSync(dir, { recursive: true, force: true }) + } + }) + }) + + describe('lightweight Run scope', () => { + it('binds creation to one pane and fences that pane when it creates another Run', () => { + const d = createDb() + const first = d.createRun({ + objective: 'First objective', + coordinatorHandle: 'term_first', + coordinatorPaneKey: 'tab_a:11111111-1111-4111-8111-111111111111' + }) + expect(first).toMatchObject({ consumer_generation: 1, legacy: 0 }) + expect(d.getCurrentRunForPane('tab_reminted:11111111-1111-4111-8111-111111111111')?.id).toBe( + first.id + ) + + const second = d.createRun({ + objective: 'Second objective', + coordinatorHandle: 'term_second', + coordinatorPaneKey: 'tab_b:11111111-1111-4111-8111-111111111111' + }) + expect(d.getRun(first.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + consumer_generation: 2 + }) + expect(d.getCurrentRunForPane('tab_b:11111111-1111-4111-8111-111111111111')?.id).toBe( + second.id + ) + }) + + it('rebinds a Run by incrementing its consumer generation', () => { + const d = createDb() + const run = d.createRun({ + objective: 'Move coordinator', + coordinatorHandle: 'term_old', + coordinatorPaneKey: 'tab_old:11111111-1111-4111-8111-111111111111' + }) + + expect( + d.bindRun({ + runId: run.id, + coordinatorHandle: 'term_new', + coordinatorPaneKey: 'tab_new:22222222-2222-4222-9222-222222222222' + }) + ).toMatchObject({ + coordinator_handle: 'term_new', + consumer_generation: 2 + }) + expect(d.getCurrentRunForPane('tab_old:11111111-1111-4111-8111-111111111111')).toBeUndefined() + expect( + d.bindRun({ + runId: LEGACY_RUN_ID, + coordinatorHandle: 'term_new', + coordinatorPaneKey: 'tab_new:22222222-2222-4222-9222-222222222222' + }) + ).toBeUndefined() + }) + + it('associates task, dispatch, message, and gate rows with the selected Run', () => { + const d = createDb() + const run = d.createRun({ + objective: 'Scoped work', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:11111111-1111-4111-8111-111111111111' + }) + const task = d.createTask({ spec: 'work', runId: run.id }) + const dispatch = d.createDispatchContext(task.id, 'term_worker') + const message = d.insertMessage({ + runId: run.id, + from: 'term_worker', + to: 'term_coord', + subject: 'status' + }) + const gate = d.createGate({ taskId: task.id, question: 'Continue?' }) + + expect(task.run_id).toBe(run.id) + expect(dispatch.run_id).toBe(run.id) + expect(message.run_id).toBe(run.id) + expect(gate.run_id).toBe(run.id) + }) + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts new file mode 100644 index 00000000000..34e9e456f6a --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './db' + +describe('OrchestrationDb worker Dispatch state', () => { + let db: OrchestrationDb | undefined + + afterEach(() => { + db?.close() + }) + + function createDb(): OrchestrationDb { + db = new OrchestrationDb(':memory:') + return db + } + + it('creates and activates a composed worker Dispatch transactionally', () => { + const d = createDb() + const task = d.createTask({ spec: 'worker' }) + const started = d.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: { topology: 'current', agent: 'codex' } + }) + expect(started).toMatchObject({ + dispatch: { status: 'pending' }, + worker: { state: 'starting', stage: 'accepted' } + }) + expect(d.getTask(task.id)?.status).toBe('dispatched') + + const capability = d.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'runtime:pty:1', + worktreeId: 'repo::worktree', + setupState: 'not_applicable', + effects: [{ kind: 'terminal', action: 'created', id: 'term_worker' }] + }) + expect(capability).toMatch(/^dcap_/) + expect(d.markWorkerDispatchReady(started.dispatch.id)).toMatchObject({ + state: 'ready', + stage: 'input_accepted' + }) + expect(d.getDispatchContextById(started.dispatch.id)).toMatchObject({ + status: 'dispatched', + assignee_handle: 'term_worker' + }) + }) + + it('commits worker-start mutation acceptance with the starting Dispatch', () => { + const d = createDb() + const task = d.createTask({ spec: 'atomic acceptance' }) + const mutationReceipt = { + callerFingerprint: 'caller_fingerprint', + requestId: 'worker_start_request', + method: 'orchestration.workerStart', + payloadHash: 'payload_hash' + } + + const started = d.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: { topology: 'current' }, + mutationReceipt + }) + + expect(d.getMutationReceipt('caller_fingerprint', 'worker_start_request')).toMatchObject({ + state: 'pending', + method: 'orchestration.workerStart' + }) + expect(d.getWorkerDispatch(started.dispatch.id)).toMatchObject({ + state: 'starting', + stage: 'accepted' + }) + expect(d.getTask(task.id)?.status).toBe('dispatched') + }) + + it('rolls back worker-start mutation acceptance when the Task cannot start', () => { + const d = createDb() + + expect(() => + d.createStartingWorkerDispatch({ + taskId: 'task_missing', + startOptions: {}, + mutationReceipt: { + callerFingerprint: 'caller_fingerprint', + requestId: 'invalid_worker_start', + method: 'orchestration.workerStart', + payloadHash: 'payload_hash' + } + }) + ).toThrow('was not found') + expect(d.getMutationReceipt('caller_fingerprint', 'invalid_worker_start')).toBeUndefined() + }) + + it('fails a composed start without losing residual resource receipts', () => { + const d = createDb() + const task = d.createTask({ spec: 'worker' }) + const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + d.recordWorkerStage({ + dispatchId: started.dispatch.id, + stage: 'terminal_created', + effects: [{ kind: 'terminal', action: 'created', id: 'term_worker' }], + residualResources: [{ kind: 'terminal', id: 'term_worker' }] + }) + + expect(d.failWorkerStart(started.dispatch.id, 'agent_readiness', 'timed out')).toMatchObject({ + state: 'failed', + stage: 'agent_readiness', + last_error: 'timed out', + residual_resources: expect.stringContaining('term_worker') + }) + expect(d.getTask(task.id)?.status).toBe('failed') + }) + + it('allows retry only from the Task current terminal Dispatch', () => { + const d = createDb() + const task = d.createTask({ spec: 'retry current' }) + const first = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + d.failWorkerStart(first.dispatch.id, 'agent_readiness', 'first failed') + const second = d.createStartingWorkerDispatch({ + taskId: task.id, + retryOf: first.dispatch.id, + startOptions: {} + }) + d.failWorkerStart(second.dispatch.id, 'agent_readiness', 'second failed') + + expect(() => + d.createStartingWorkerDispatch({ + taskId: task.id, + retryOf: first.dispatch.id, + startOptions: {} + }) + ).toThrow('cannot retry') + expect( + d.createStartingWorkerDispatch({ + taskId: task.id, + retryOf: second.dispatch.id, + startOptions: {} + }).worker.state + ).toBe('starting') + }) + + it('treats abandon of a superseded Dispatch as a no-op', () => { + const d = createDb() + const task = d.createTask({ spec: 'stale abandon' }) + const first = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + d.failWorkerStart(first.dispatch.id, 'agent_readiness', 'first failed') + const second = d.createStartingWorkerDispatch({ + taskId: task.id, + retryOf: first.dispatch.id, + startOptions: {} + }) + d.prepareStartingWorkerAuthority({ + dispatchId: second.dispatch.id, + handle: 'term_replacement', + paneKey: 'tab_replacement:leaf_replacement', + processIncarnation: 'runtime:pty:2', + worktreeId: 'repo::worktree', + setupState: 'not_applicable', + effects: [] + }) + d.markWorkerDispatchReady(second.dispatch.id) + + expect(d.abandonWorkerDispatch(first.dispatch.id)).toMatchObject({ + disposition: 'stale', + worker: { state: 'failed' } + }) + expect(d.getTask(task.id)?.status).toBe('dispatched') + expect(d.getWorkerDispatch(second.dispatch.id)?.state).toBe('ready') + expect( + d.settleWorkerReport({ + taskId: task.id, + dispatchId: second.dispatch.id, + outcome: 'succeeded', + result: '{}' + }) + ).toMatchObject({ action: 'settled' }) + expect(d.getTask(task.id)?.status).toBe('completed') + }) + + it('lets the stop fence win before a late worker completion', () => { + const d = createDb() + const task = d.createTask({ spec: 'race' }) + const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + d.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'runtime:pty:1', + worktreeId: 'repo::worktree', + setupState: 'not_applicable', + effects: [] + }) + d.markWorkerDispatchReady(started.dispatch.id) + + expect(d.beginWorkerStop(started.dispatch.id).disposition).toBe('stopping') + expect( + d.settleWorkerReport({ + taskId: task.id, + dispatchId: started.dispatch.id, + outcome: 'succeeded', + result: '{}' + }) + ).toMatchObject({ action: 'rejected', code: 'inactive_dispatch' }) + expect(d.settleWorkerStop(started.dispatch.id).state).toBe('stopped') + expect(d.getTask(task.id)?.status).toBe('blocked') + }) + + it('allows explicit stop recovery from uncertain local and remote starts', () => { + const d = createDb() + const task = d.createTask({ spec: 'uncertain local start' }) + const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + d.markWorkerStartUnknown(started.dispatch.id, 'agent_readiness', 'connection lost') + + expect(d.beginWorkerStop(started.dispatch.id)).toMatchObject({ + disposition: 'stopping', + worker: { state: 'stopping' } + }) + + d.createRemoteDispatchAttachment({ + dispatchId: 'ctx_remote_unknown', + taskId: 'task_remote_unknown', + homePeerFingerprint: 'home_peer', + protocolVersion: 1, + runtimeEpoch: 'worker_epoch', + mutationReceipt: { + callerFingerprint: 'home_peer', + requestId: 'remote_unknown_start', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_unknown_payload' + } + }) + d.recordRemoteAttachmentStage({ + dispatchId: 'ctx_remote_unknown', + stage: 'agent_readiness', + state: 'start_unknown', + terminalHandle: 'term_remote_worker' + }) + + expect(d.beginRemoteAttachmentStop('ctx_remote_unknown')).toMatchObject({ + state: 'stopping', + stage: 'stop_requested', + capability_hash: null + }) + }) + + it('returns already-settled when completion wins before stop', () => { + const d = createDb() + const task = d.createTask({ spec: 'race' }) + const started = d.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + d.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'runtime:pty:1', + worktreeId: 'repo::worktree', + setupState: 'not_applicable', + effects: [] + }) + d.markWorkerDispatchReady(started.dispatch.id) + expect( + d.settleWorkerReport({ + taskId: task.id, + dispatchId: started.dispatch.id, + outcome: 'succeeded', + result: '{}' + }) + ).toMatchObject({ action: 'settled' }) + + expect(d.beginWorkerStop(started.dispatch.id)).toMatchObject({ + disposition: 'already_settled', + worker: { state: 'succeeded' } + }) + }) +}) diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts index ef133551034..4497d9c8ad5 100644 --- a/src/main/runtime/orchestration/preamble.test.ts +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -45,9 +45,12 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('reportPath') expect(result).toContain('--task-id task_abc123') expect(result).toContain('--dispatch-id ctx_def456') + expect(result).toContain('--outcome succeeded') + expect(result).toContain('replace it with --outcome failed') expect(result).toContain('--files-modified "path/a,path/b"') expect(result).toContain('--report-path ""') - expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) + expect(result).toMatch(/orchestration send --from term_worker/) + expect(result).not.toContain('orchestration send --to term_coord') }) it( @@ -85,12 +88,12 @@ describe('buildDispatchPreamble', () => { expect(result).toContain('--task-id task_abc123') expect(result).toContain('--dispatch-id ctx_def456') expect(result).toContain('--phase ""') - expect(result).toMatch(/orchestration send --to term_coord --from term_worker/) + expect(result).toMatch(/orchestration send --from term_worker/) }) it('includes ask block with BEHAVIOR RULE #1 forbidding AskUserQuestion', () => { const result = buildDispatchPreamble(baseParams()) - expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) + expect(result).toMatch(/orchestration ask --from term_worker/) expect(result).toContain('--question') expect(result).toContain('--timeout-ms 600000') // Why: the exact phrase is asserted so the rule can't be trimmed away by @@ -101,21 +104,27 @@ describe('buildDispatchPreamble', () => { // else (e.g., not in an example payload or header). Count occurrences // of the exact token as a sanity check. const occurrences = (result.match(/AskUserQuestion/g) ?? []).length - // Three mentions: the one-liner ban, the TUI-prompt rationale, and the - // "when tempted to reach for AskUserQuestion" closing line. - expect(occurrences).toBe(3) + expect(occurrences).toBe(2) }) it('binds every injected worker command to the dispatched terminal', () => { const result = buildDispatchPreamble(baseParams()) - expect(result).toMatch(/orchestration ask --to term_coord --from term_worker/) - expect(result).toMatch( - /orchestration send --to term_coord --from term_worker \\\n --type escalation/ - ) + expect(result).toMatch(/orchestration ask --from term_worker/) + expect(result).toMatch(/orchestration send --from term_worker \\\n --type escalation/) expect(result).toContain('orchestration check --terminal term_worker') }) + it('carries the minted Dispatch capability on lifecycle and question commands', () => { + const result = buildDispatchPreamble({ + ...baseParams(), + dispatchCapability: 'dcap_test_secret' + }) + + expect(result.match(/--dispatch-capability dcap_test_secret/g)).toHaveLength(4) + expect(result).not.toContain('"dispatchCapability"') + }) + it('tells prompt-returning workers to idle without post-done polling', () => { const result = buildDispatchPreamble(baseParams()) const section = afterWorkerDoneSection(result) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts index a8012c56854..4c1864f5507 100644 --- a/src/main/runtime/orchestration/preamble.ts +++ b/src/main/runtime/orchestration/preamble.ts @@ -8,6 +8,7 @@ export type PreambleParams = { // prevents stale messages from a previously-failed dispatch from completing // or refreshing the retry. dispatchId: string + dispatchCapability?: string taskSpec: string coordinatorHandle: string workerHandle: string @@ -52,6 +53,9 @@ export function buildDispatchPreamble(params: PreambleParams): string { cli, workerKind: params.workerKind ?? 'prompt-returning-agent' }) + const capabilityFlag = params.dispatchCapability + ? ` --dispatch-capability ${params.dispatchCapability}` + : '' const header = `You are working inside Orca, a multi-agent IDE. You are a dispatched worker. Your coordinator's terminal handle is: ${params.coordinatorHandle} @@ -62,7 +66,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. === CLI COMMANDS === - # Report task completion (REQUIRED when done — even on failure). + # Report the terminal task outcome (REQUIRED exactly once). # # RULE: --body must be a 3-sentence executive summary (what you did, # what you found, what's left). Never send an empty body; the coordinator @@ -70,14 +74,15 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # If you produced a long-form artifact, include its path as # payload.reportPath so the coordinator can find it without a file search. # - # RULE: send worker_done exactly once. Failure is still a worker_done - # with subject like "Failed: " — never silently exit. + # RULE: send worker_done exactly once. Use --outcome succeeded when the + # requested work is done, or replace it with --outcome failed when it is not. + # Never encode failure only in prose and never silently exit. # Include BOTH taskId and dispatchId in the payload so a late completion # from a failed retry cannot complete the current dispatch. - ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ + ${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} \\ --type worker_done --subject "" \\ --body "<3-sentence summary: what you did, what you found, what's left>" \\ - --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ + --task-id ${params.taskId} --dispatch-id ${params.dispatchId} --outcome succeeded \\ --files-modified "path/a,path/b" \\ --report-path "" @@ -91,7 +96,7 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # attributes the heartbeat to the specific dispatch context, not just # the task, so a straggler heartbeat from a previously-failed dispatch # cannot mask a hung retry. - ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ + ${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} \\ --type heartbeat --subject "alive" \\ --task-id ${params.taskId} --dispatch-id ${params.dispatchId} \\ --phase "" @@ -104,18 +109,18 @@ Slack, GitHub comments, or any other channel to reach a human during the run. # coordinator cannot see and cannot answer — your session will hang forever # waiting on a human. Every interactive question goes through \`ask\` below. # - # The \`ask\` verb is a thin wrapper: it sends a decision_gate message and - # blocks on \`check --wait\` until the coordinator replies, then prints the - # reply body. Use it anywhere you would otherwise have reached for - # AskUserQuestion. - ${cli} orchestration ask --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ + # The \`ask\` verb durably records a question in this Dispatch's Run and + # blocks until the coordinator replies, then prints the reply body. If the + # call times out or disconnects, resume with the returned message ID instead + # of creating a duplicate question. + ${cli} orchestration ask --from ${params.workerHandle}${capabilityFlag} \\ --question "" \\ --options "" \\ --timeout-ms 600000 # Escalate a blocker or failure (pre-completion, when you need the # coordinator to do something before you can continue): - ${cli} orchestration send --to ${params.coordinatorHandle} --from ${params.workerHandle} \\ + ${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} \\ --type escalation --subject "Blocked: " \\ --body "
    " \\ --task-id ${params.taskId} diff --git a/src/main/runtime/orchestration/setup-completion-signal.test.ts b/src/main/runtime/orchestration/setup-completion-signal.test.ts new file mode 100644 index 00000000000..0f2b3976f1e --- /dev/null +++ b/src/main/runtime/orchestration/setup-completion-signal.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from 'vitest' +import { buildObservedSetupCommand, createSetupCompletionScanner } from './setup-completion-signal' + +describe('orchestration setup completion signal', () => { + it('preserves a POSIX setup exit code in a visible completion signal', () => { + const { command } = buildObservedSetupCommand( + '/repo/.git/orca/setup-runner.sh', + 'posix', + 'token-posix' + ) + + expect(command).toContain('bash /repo/.git/orca/setup-runner.sh') + expect(command).toContain('__ORCA_SETUP_COMPLETE__:token-posix:%s\\n') + expect(command).toContain('"$status"') + expect(command).toContain('exit "$status"') + }) + + it('preserves a native Windows setup path and exit code without shell interpolation', () => { + const runnerPath = 'C:\\repo %name%!^&\\.git\\orca\\setup-runner.cmd' + const observed = buildObservedSetupCommand(runnerPath, 'windows', 'token-windows') + const encodedCommand = observed.command.split(' ').at(-1) + const script = Buffer.from(encodedCommand ?? '', 'base64').toString('utf16le') + + expect(observed.command).toContain('powershell.exe -NoLogo -NoProfile -NonInteractive') + expect(observed.env).toEqual({ ORCA_SETUP_RUNNER_PATH: runnerPath }) + expect(script).toContain('& $runner') + expect(script).toContain('__ORCA_SETUP_COMPLETE__:token-windows:') + expect(script).toContain('exit $status') + expect(script).not.toContain(runnerPath) + }) + + it('keeps a WSL runner on the POSIX completion path', () => { + const { command } = buildObservedSetupCommand( + '\\\\wsl.localhost\\Ubuntu\\repo\\.git\\orca\\setup-runner.sh', + 'windows', + 'token-wsl' + ) + + expect(command).toContain('bash /repo/.git/orca/setup-runner.sh') + expect(command).toContain('__ORCA_SETUP_COMPLETE__:token-wsl:%s\\n') + expect(command).toContain('exit "$status"') + }) + + it('recognizes one completion signal across output chunk boundaries', () => { + const onComplete = vi.fn() + const scanner = createSetupCompletionScanner('token-chunks', onComplete) + + scanner.scan('installing...\r\n__ORCA_SETUP_COMPLETE__:wrong:0\r\n__ORCA_SETUP_COMP') + scanner.scan('LETE__:token-chunks:1') + expect(onComplete).not.toHaveBeenCalled() + scanner.scan('7\r') + expect(onComplete).not.toHaveBeenCalled() + scanner.scan('\nPS C:\\repo>') + scanner.scan('__ORCA_SETUP_COMPLETE__:token-chunks:0\r\n') + + expect(onComplete).toHaveBeenCalledOnce() + expect(onComplete).toHaveBeenCalledWith(17) + }) +}) diff --git a/src/main/runtime/orchestration/setup-completion-signal.ts b/src/main/runtime/orchestration/setup-completion-signal.ts new file mode 100644 index 00000000000..17819c12a0c --- /dev/null +++ b/src/main/runtime/orchestration/setup-completion-signal.ts @@ -0,0 +1,80 @@ +import { + resolveSetupRunnerCommand, + type SetupRunnerCommandPlatform +} from '../../../shared/setup-runner-command' + +const SETUP_COMPLETION_PREFIX = '__ORCA_SETUP_COMPLETE__:' +const SETUP_COMPLETION_CARRY_LENGTH = SETUP_COMPLETION_PREFIX.length + 96 +const WINDOWS_SETUP_RUNNER_ENV = 'ORCA_SETUP_RUNNER_PATH' + +export function buildObservedSetupCommand( + runnerScriptPath: string, + platform: SetupRunnerCommandPlatform, + completionToken: string +): { command: string; env?: Record } { + const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform) + if (resolution.shell === 'windows') { + const script = [ + `$runner = $env:${WINDOWS_SETUP_RUNNER_ENV}`, + '& $runner', + '$succeeded = $?', + '$status = $LASTEXITCODE', + 'if ($null -eq $status) { $status = if ($succeeded) { 0 } else { 1 } }', + `Write-Output ('${completionPrefix(completionToken)}' + $status)`, + 'exit $status' + ].join('; ') + return { + command: `powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from( + script, + 'utf16le' + ).toString('base64')}`, + env: { [WINDOWS_SETUP_RUNNER_ENV]: resolution.runnerScriptPathForShell } + } + } + + const script = [ + `( ${resolution.command} )`, + 'status=$?', + `printf '\\n${completionPrefix(completionToken)}%s\\n' "$status"`, + 'exit "$status"' + ].join('; ') + return { command: `bash -lc ${quotePosixArg(script)}` } +} + +export function createSetupCompletionScanner( + completionToken: string, + onComplete: (exitCode: number) => void +): { + scan: (data: string) => void +} { + const expectedPrefix = completionPrefix(completionToken) + let carry = '' + let completed = false + return { + scan(data: string): void { + if (completed || data.length === 0) { + return + } + const combined = `${carry}${data}` + const markerIndex = combined.lastIndexOf(expectedPrefix) + if (markerIndex >= 0) { + const suffix = combined.slice(markerIndex + expectedPrefix.length) + const match = suffix.match(/^(-?\d+)\r?\n/) + if (match) { + completed = true + onComplete(Number.parseInt(match[1], 10)) + return + } + } + carry = combined.slice(-SETUP_COMPLETION_CARRY_LENGTH) + } + } +} + +function completionPrefix(completionToken: string): string { + return `${SETUP_COMPLETION_PREFIX}${completionToken}:` +} + +function quotePosixArg(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'` +} diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 58b86e41b08..eac7868da20 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -1,12 +1,16 @@ -export type MessageType = - | 'status' - | 'dispatch' - | 'worker_done' - | 'merge_ready' - | 'escalation' - | 'handoff' - | 'decision_gate' - | 'heartbeat' +export const MESSAGE_TYPES = [ + 'status', + 'dispatch', + 'worker_done', + 'merge_ready', + 'escalation', + 'handoff', + 'decision_gate', + 'question', + 'heartbeat' +] as const + +export type MessageType = (typeof MESSAGE_TYPES)[number] export type MessagePriority = 'normal' | 'high' | 'urgent' @@ -14,12 +18,158 @@ export type TaskStatus = 'pending' | 'ready' | 'dispatched' | 'completed' | 'fai export type DispatchStatus = 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken' +export type WorkerReportOutcome = 'succeeded' | 'failed' + +export type WorkerReportSettlement = + | { action: 'settled'; outcome: WorkerReportOutcome; duplicate: boolean } + | { + action: 'rejected' + code: + | 'unknown_task' + | 'unknown_dispatch' + | 'task_dispatch_mismatch' + | 'inactive_dispatch' + | 'stale_dispatch' + reason: string + } + export type GateStatus = 'pending' | 'resolved' | 'timeout' export type CoordinatorStatus = 'idle' | 'running' | 'completed' | 'failed' +export type RunRow = { + id: string + objective: string + home_database: string + coordinator_handle: string | null + coordinator_pane_key: string | null + consumer_generation: number + legacy: number + created_at: string + updated_at: string +} + +export type DeliveryStatus = 'outstanding' | 'acknowledged' | 'fenced' + +export type DeliveryRow = { + id: string + run_id: string + consumer_generation: number + message_ids: string + status: DeliveryStatus + created_at: string + acknowledged_at: string | null +} + +export type QuestionStatus = 'pending' | 'answered' | 'closed' + +export type QuestionRow = { + message_id: string + run_id: string + dispatch_id: string + asker_handle: string + status: QuestionStatus + answer_message_id: string | null + answer_body: string | null + answered_by_generation: number | null + created_at: string + answered_at: string | null + closed_at: string | null +} + +export type MutationState = 'pending' | 'completed' + +export type MutationReceiptRow = { + caller_fingerprint: string + request_id: string + method: string + payload_hash: string + state: MutationState + receipt: string | null + created_at: string + updated_at: string +} + +export type WorkerDispatchState = + | 'starting' + | 'ready' + | 'start_unknown' + | 'failed' + | 'succeeded' + | 'stopping' + | 'stop_unknown' + | 'stopped' + | 'abandoned' + +export type WorkerDispatchRow = { + dispatch_id: string + runtime_epoch: string | null + state: WorkerDispatchState + stage: string + worktree_id: string | null + agent_terminal_handle: string | null + setup_state: string + effects: string + residual_resources: string + start_options: string + last_error: string | null + created_at: string + updated_at: string +} + +export type FederatedDispatchRow = { + dispatch_id: string + environment_id: string + environment_name: string + peer_fingerprint: string + remote_runtime_epoch: string | null + protocol_version: number + remote_worktree_id: string | null + remote_terminal_handle: string | null + to_home_imported_sequence: number + created_at: string + updated_at: string +} + +export type RemoteDispatchAttachmentRow = { + dispatch_id: string + task_id: string + home_peer_fingerprint: string + protocol_version: number + runtime_epoch: string + capability_hash: string | null + pane_key: string | null + process_incarnation: string | null + state: WorkerDispatchState + stage: string + worktree_id: string | null + terminal_handle: string | null + setup_state: string + effects: string + residual_resources: string + to_worker_imported_sequence: number + last_error: string | null + created_at: string + updated_at: string +} + +export type FederationRelayDirection = 'to_home' | 'to_worker' + +export type FederationRelayItemRow = { + dispatch_id: string + direction: FederationRelayDirection + sequence: number + message_id: string + kind: string + payload: string + byte_count: number + acked_at: string | null + created_at: string +} + export type MessageRow = { id: string + run_id: string from_handle: string to_handle: string subject: string @@ -37,6 +187,7 @@ export type MessageRow = { export type TaskRow = { id: string + run_id: string parent_id: string | null created_by_terminal_handle: string | null task_title: string | null @@ -51,9 +202,13 @@ export type TaskRow = { export type DispatchContextRow = { id: string + run_id: string task_id: string assignee_handle: string | null assignee_pane_key: string | null + capability_hash: string | null + process_incarnation: string | null + capability_revoked_at: string | null status: DispatchStatus failure_count: number last_failure: string | null @@ -65,6 +220,7 @@ export type DispatchContextRow = { export type DecisionGateRow = { id: string + run_id: string task_id: string question: string options: string diff --git a/src/main/runtime/orchestration/worker-output-cursor.test.ts b/src/main/runtime/orchestration/worker-output-cursor.test.ts new file mode 100644 index 00000000000..e109699a8d7 --- /dev/null +++ b/src/main/runtime/orchestration/worker-output-cursor.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { decodeWorkerOutputCursor, encodeWorkerOutputCursor } from './worker-output-cursor' + +describe('worker output cursors', () => { + it('round-trips a source-pinned cursor without exposing source details', () => { + const cursor = encodeWorkerOutputCursor('dispatch_1', 'transcript', 'source_digest', 42) + + expect(cursor).toMatch(/^owr1_/) + expect(cursor).not.toContain('source_digest') + expect(decodeWorkerOutputCursor(cursor, 'dispatch_1')).toEqual({ + source: 'transcript', + sourceIdentity: 'source_digest', + position: 42, + legacy: false + }) + }) + + it('accepts legacy numeric terminal cursors', () => { + expect(decodeWorkerOutputCursor(0, 'dispatch_1')).toEqual({ + source: 'terminal', + sourceIdentity: null, + position: 0, + legacy: true + }) + expect(decodeWorkerOutputCursor('17', 'dispatch_1')).toMatchObject({ + source: 'terminal', + position: 17, + legacy: true + }) + }) + + it('rejects another Dispatch and malformed cursor data', () => { + const cursor = encodeWorkerOutputCursor('dispatch_1', 'terminal', 'terminal_digest', 1) + + expect(() => decodeWorkerOutputCursor(cursor, 'dispatch_2')).toThrow( + expect.objectContaining({ code: 'cursor_dispatch_mismatch' }) + ) + expect(() => decodeWorkerOutputCursor('owr1_not-json', 'dispatch_1')).toThrow( + expect.objectContaining({ code: 'cursor_invalid' }) + ) + }) +}) diff --git a/src/main/runtime/orchestration/worker-output-cursor.ts b/src/main/runtime/orchestration/worker-output-cursor.ts new file mode 100644 index 00000000000..31e67f4b5f9 --- /dev/null +++ b/src/main/runtime/orchestration/worker-output-cursor.ts @@ -0,0 +1,122 @@ +import { createHash } from 'node:crypto' +import { OrchestrationError } from './orchestration-error' + +const WORKER_OUTPUT_CURSOR_PREFIX = 'owr1_' +const WORKER_OUTPUT_CURSOR_MAX_LENGTH = 2_048 + +type WorkerOutputCursorPayload = { + v: 1 + d: string + s: 'terminal' | 'transcript' + i: string + p: number +} + +export type DecodedWorkerOutputCursor = + | { + source: 'terminal' + sourceIdentity: string | null + position: number + legacy: boolean + } + | { + source: 'transcript' + sourceIdentity: string + position: number + legacy: false + } + +export function createWorkerOutputSourceIdentity(fields: readonly string[]): string { + return createHash('sha256').update(JSON.stringify(fields)).digest('base64url').slice(0, 32) +} + +export function encodeWorkerOutputCursor( + dispatchId: string, + source: WorkerOutputCursorPayload['s'], + sourceIdentity: string, + position: number +): string { + const payload: WorkerOutputCursorPayload = { + v: 1, + d: dispatchId, + s: source, + i: sourceIdentity, + p: position + } + return `${WORKER_OUTPUT_CURSOR_PREFIX}${Buffer.from(JSON.stringify(payload)).toString('base64url')}` +} + +export function decodeWorkerOutputCursor( + cursor: string | number | undefined, + dispatchId: string +): DecodedWorkerOutputCursor | null { + if (cursor === undefined) { + return null + } + if (typeof cursor === 'number') { + return decodeLegacyTerminalCursor(cursor) + } + if (/^\d+$/.test(cursor)) { + return decodeLegacyTerminalCursor(Number.parseInt(cursor, 10)) + } + if ( + cursor.length > WORKER_OUTPUT_CURSOR_MAX_LENGTH || + !cursor.startsWith(WORKER_OUTPUT_CURSOR_PREFIX) + ) { + throw invalidCursor() + } + let parsed: unknown + try { + parsed = JSON.parse( + Buffer.from(cursor.slice(WORKER_OUTPUT_CURSOR_PREFIX.length), 'base64url').toString('utf8') + ) + } catch { + throw invalidCursor() + } + if (!isWorkerOutputCursorPayload(parsed)) { + throw invalidCursor() + } + if (parsed.d !== dispatchId) { + throw new OrchestrationError( + 'cursor_dispatch_mismatch', + 'The worker-read cursor belongs to a different Dispatch.' + ) + } + return { + source: parsed.s, + sourceIdentity: parsed.i, + position: parsed.p, + legacy: false + } +} + +function decodeLegacyTerminalCursor(position: number): DecodedWorkerOutputCursor { + if (!Number.isSafeInteger(position) || position < 0) { + throw invalidCursor() + } + return { source: 'terminal', sourceIdentity: null, position, legacy: true } +} + +function isWorkerOutputCursorPayload(value: unknown): value is WorkerOutputCursorPayload { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return false + } + const payload = value as Record + return ( + payload.v === 1 && + typeof payload.d === 'string' && + payload.d.length > 0 && + payload.d.length <= 512 && + (payload.s === 'terminal' || payload.s === 'transcript') && + typeof payload.i === 'string' && + payload.i.length > 0 && + payload.i.length <= 128 && + typeof payload.p === 'number' && + Number.isSafeInteger(payload.p) && + payload.p >= 0 + ) +} + +function invalidCursor(): OrchestrationError { + return new OrchestrationError('cursor_invalid', 'The worker-read cursor is invalid.') +} diff --git a/src/main/runtime/orchestration/worker-provider-session.test.ts b/src/main/runtime/orchestration/worker-provider-session.test.ts new file mode 100644 index 00000000000..0d36c65e732 --- /dev/null +++ b/src/main/runtime/orchestration/worker-provider-session.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import { selectExactWorkerProviderSession } from './worker-provider-session' + +function status( + paneKey: string, + sessionId: string, + overrides: Partial = {} +): AgentStatusIpcPayload { + return { + paneKey, + connectionId: null, + receivedAt: 200, + stateStartedAt: 190, + state: 'working', + prompt: '', + agentType: 'codex', + providerSession: { key: 'session_id', id: sessionId }, + ...overrides + } +} + +describe('exact worker provider session selection', () => { + it('selects only the current pane, connection, and observation window', () => { + const selected = selectExactWorkerProviderSession({ + paneKey: 'tab:worker', + processIncarnation: 'pty:incarnation', + connectionId: 'ssh-windows', + launchToken: undefined, + observedAfter: 150, + statuses: [ + status('tab:sibling', 'sibling', { connectionId: 'ssh-windows', receivedAt: 300 }), + status('tab:worker', 'old', { connectionId: 'ssh-windows', receivedAt: 100 }), + status('tab:worker', 'wrong-host', { connectionId: 'ssh-mac', receivedAt: 400 }), + status('tab:worker', 'exact', { connectionId: 'ssh-windows', receivedAt: 250 }) + ] + }) + + expect(selected).toEqual({ + paneKey: 'tab:worker', + processIncarnation: 'pty:incarnation', + agent: 'codex', + providerSession: { key: 'session_id', id: 'exact' }, + observedAt: 250 + }) + }) + + it('rejects stale and provider-session-only rows', () => { + expect( + selectExactWorkerProviderSession({ + paneKey: 'tab:worker', + processIncarnation: 'pty:incarnation', + connectionId: null, + launchToken: undefined, + observedAfter: 300, + statuses: [ + status('tab:worker', 'stale', { receivedAt: 200 }), + status('tab:worker', 'identity-only', { + receivedAt: 400, + providerSessionOnly: true + }) + ] + }) + ).toBeNull() + }) + + it('rejects a prior process snapshot when the launch token changed', () => { + expect( + selectExactWorkerProviderSession({ + paneKey: 'tab:worker', + processIncarnation: 'pty:new-incarnation', + connectionId: null, + launchToken: 'launch-new', + observedAfter: 0, + statuses: [status('tab:worker', 'prior', { launchToken: 'launch-old' })] + }) + ).toBeNull() + }) +}) diff --git a/src/main/runtime/orchestration/worker-provider-session.ts b/src/main/runtime/orchestration/worker-provider-session.ts new file mode 100644 index 00000000000..eb3e7064583 --- /dev/null +++ b/src/main/runtime/orchestration/worker-provider-session.ts @@ -0,0 +1,34 @@ +import type { AgentStatusIpcPayload } from '../../../shared/agent-status-types' +import type { ExactWorkerProviderSession } from '../../../shared/orchestration-worker-output' + +export function selectExactWorkerProviderSession(args: { + paneKey: string + processIncarnation: string + connectionId: string | null | undefined + launchToken: string | null | undefined + observedAfter: number + statuses: readonly AgentStatusIpcPayload[] +}): ExactWorkerProviderSession | null { + const status = args.statuses + .filter( + (entry) => + entry.paneKey === args.paneKey && + (args.connectionId === undefined || entry.connectionId === args.connectionId) && + (!args.launchToken || entry.launchToken === args.launchToken) && + entry.providerSessionOnly !== true && + entry.providerSession !== undefined && + entry.agentType !== undefined && + entry.receivedAt >= args.observedAfter + ) + .sort((left, right) => right.receivedAt - left.receivedAt)[0] + if (!status?.providerSession || !status.agentType) { + return null + } + return { + paneKey: args.paneKey, + processIncarnation: args.processIncarnation, + agent: status.agentType, + providerSession: { ...status.providerSession }, + observedAt: status.receivedAt + } +} diff --git a/src/main/runtime/orchestration/worker-transcript-payload.test.ts b/src/main/runtime/orchestration/worker-transcript-payload.test.ts new file mode 100644 index 00000000000..94f9506db09 --- /dev/null +++ b/src/main/runtime/orchestration/worker-transcript-payload.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { + boundWorkerTranscriptMessages, + redactWorkerTerminalLines +} from './worker-transcript-payload' + +describe('worker transcript wire bounds', () => { + it('clips oversized blocks and omits local image paths', () => { + const result = boundWorkerTranscriptMessages([ + { + id: 'message-1', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { type: 'text', text: 'x'.repeat(5_000) }, + { type: 'image-ref', path: 'C:\\Users\\worker\\secret.png', alt: 'screenshot' } + ] + } + ]) + + expect(result.messages[0]?.blocks[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('… (truncated)') + }) + expect(result.messages[0]?.blocks[1]).toEqual({ + type: 'image-ref', + alt: 'screenshot' + }) + expect(JSON.stringify(result)).not.toContain('C:\\\\Users') + expect(result.warnings).toContain('Local image paths were omitted from transcript output.') + }) + + it('keeps fallback identifiers stable without exposing the transcript path', () => { + const transcriptPath = 'C:\\Users\\worker\\.codex\\session.jsonl' + const message = { + id: `${transcriptPath}:0000000000000042`, + turnId: `${transcriptPath}:0000000000000001`, + role: 'assistant' as const, + timestamp: null, + source: 'transcript' as const, + blocks: [{ type: 'image-ref' as const, url: `file:///${transcriptPath}` }] + } + + const first = boundWorkerTranscriptMessages([message], transcriptPath) + const second = boundWorkerTranscriptMessages([message], transcriptPath) + + expect(first.messages).toEqual(second.messages) + expect(first.messages[0]?.id).toMatch(/^worker-message-/) + expect(first.messages[0]?.turnId).toMatch(/^worker-message-/) + expect(first.messages[0]?.blocks[0]).toEqual({ type: 'image-ref' }) + expect(JSON.stringify(first)).not.toContain('Users') + expect(first.warnings).toEqual( + expect.arrayContaining([ + 'Transcript-backed message identifiers were made opaque.', + 'Local image paths were omitted from transcript output.' + ]) + ) + }) + + it('redacts dispatch capabilities from prose and tool payloads', () => { + const capability = `dcap_${'A'.repeat(43)}` + const result = boundWorkerTranscriptMessages([ + { + id: 'message-secret', + role: 'assistant', + timestamp: null, + source: 'transcript', + blocks: [ + { type: 'text', text: `Use --dispatch-capability ${capability}` }, + { + type: 'tool-call', + name: 'exec_command', + input: { + cmd: `orca orchestration send --dispatch-capability ${capability}`, + [capability]: 'secret key' + } + }, + { type: 'tool-result', output: `echoed ${capability}` } + ] + } + ]) + + expect(JSON.stringify(result)).not.toContain(capability) + expect(JSON.stringify(result.messages)).toContain('[dispatch capability redacted]') + expect(result.warnings).toContain( + 'Dispatch capability tokens were redacted from transcript output.' + ) + }) + + it('redacts dispatch capabilities from terminal fallback lines', () => { + const capability = `dcap_${'A'.repeat(43)}` + + expect(redactWorkerTerminalLines([`send --dispatch-capability ${capability}`, 'safe'])).toEqual( + { + lines: ['send --dispatch-capability [dispatch capability redacted]', 'safe'], + warnings: ['Dispatch capability tokens were redacted from terminal output.'] + } + ) + }) +}) diff --git a/src/main/runtime/orchestration/worker-transcript-payload.ts b/src/main/runtime/orchestration/worker-transcript-payload.ts new file mode 100644 index 00000000000..e4a5c0b3a58 --- /dev/null +++ b/src/main/runtime/orchestration/worker-transcript-payload.ts @@ -0,0 +1,226 @@ +import { createHash } from 'node:crypto' +import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native-chat-types' + +export const DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 40 +export const MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT = 50 +const MAX_WORKER_TRANSCRIPT_BLOCKS = 6 +const MAX_WORKER_TRANSCRIPT_BLOCK_CHARS = 1_200 +const MAX_WORKER_TRANSCRIPT_INPUT_ITEMS = 20 +const MAX_WORKER_TRANSCRIPT_INPUT_NODES = 100 +const MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES = 512 * 1024 +const TRUNCATION_MARKER = '\n… (truncated)' +const DISPATCH_CAPABILITY_PATTERN = /\bdcap_[A-Za-z0-9_-]{20,}\b/g +const DISPATCH_CAPABILITY_REDACTION = '[dispatch capability redacted]' + +export function clampWorkerTranscriptLimit(limit: number | undefined): number { + if (!Number.isFinite(limit) || (limit ?? 0) <= 0) { + return DEFAULT_WORKER_TRANSCRIPT_MESSAGE_LIMIT + } + return Math.min(Math.floor(limit!), MAX_WORKER_TRANSCRIPT_MESSAGE_LIMIT) +} + +export function redactWorkerTerminalLines(lines: readonly string[]): { + lines: string[] + warnings: string[] +} { + let redacted = false + const bounded = lines.map((line) => { + const result = replaceDispatchCapabilities(line) + redacted ||= result.redacted + return result.value + }) + return { + lines: bounded, + warnings: redacted ? ['Dispatch capability tokens were redacted from terminal output.'] : [] + } +} + +export function boundWorkerTranscriptMessages( + messages: readonly NativeChatMessage[], + transcriptPath?: string +): { + messages: NativeChatMessage[] + limited: boolean + warnings: string[] +} { + const warnings = new Set() + const bounded: NativeChatMessage[] = [] + let bytes = 2 + for (const message of messages) { + const next = boundMessage(message, transcriptPath, warnings) + const serializedBytes = Buffer.byteLength(JSON.stringify(next), 'utf8') + 1 + if (bounded.length > 0 && bytes + serializedBytes > MAX_WORKER_TRANSCRIPT_RESPONSE_BYTES) { + warnings.add('Transcript response was clipped to the wire-size limit.') + return { messages: bounded, limited: true, warnings: [...warnings] } + } + bounded.push(next) + bytes += serializedBytes + } + return { messages: bounded, limited: false, warnings: [...warnings] } +} + +function boundMessage( + message: NativeChatMessage, + transcriptPath: string | undefined, + warnings: Set +): NativeChatMessage { + const blocks = message.blocks.slice(0, MAX_WORKER_TRANSCRIPT_BLOCKS) + if (blocks.length < message.blocks.length) { + warnings.add('Some transcript blocks were omitted from oversized messages.') + } + return { + ...message, + id: boundIdentifier(message.id, transcriptPath, warnings), + ...(message.turnId + ? { turnId: boundIdentifier(message.turnId, transcriptPath, warnings) } + : {}), + blocks: blocks.map((block) => boundBlock(block, warnings)) + } +} + +function boundBlock(block: NativeChatBlock, warnings: Set): NativeChatBlock { + if (block.type === 'text') { + return { ...block, text: clipText(block.text, warnings) } + } + if (block.type === 'tool-result') { + return { ...block, output: clipText(block.output, warnings) } + } + if (block.type === 'tool-call') { + const budget = { + remaining: MAX_WORKER_TRANSCRIPT_BLOCK_CHARS, + nodes: MAX_WORKER_TRANSCRIPT_INPUT_NODES + } + return { + ...block, + name: clipMetadata(block.name, warnings), + input: boundToolInput(block.input, budget, 0, warnings) + } + } + if (block.path || (block.url && isLocalFileLocator(block.url))) { + warnings.add('Local image paths were omitted from transcript output.') + return { + type: 'image-ref', + ...(block.alt ? { alt: clipText(block.alt, warnings) } : {}) + } + } + return { + ...block, + ...(block.url ? { url: clipMetadata(block.url, warnings) } : {}), + ...(block.alt ? { alt: clipText(block.alt, warnings) } : {}) + } +} + +function boundIdentifier( + value: string, + transcriptPath: string | undefined, + warnings: Set +): string { + if (transcriptPath && value.includes(transcriptPath)) { + warnings.add('Transcript-backed message identifiers were made opaque.') + return `worker-message-${createHash('sha256').update(value).digest('base64url').slice(0, 32)}` + } + return clipMetadata(value, warnings) +} + +function isLocalFileLocator(value: string): boolean { + return ( + /^file:/i.test(value) || + /^[a-z]:[\\/]/i.test(value) || + value.startsWith('/') || + value.startsWith('\\\\') + ) +} + +function clipMetadata(value: string, warnings: Set): string { + const redacted = redactSensitiveText(value, warnings) + if (redacted.length <= 512) { + return redacted + } + warnings.add('Oversized transcript metadata was clipped.') + return redacted.slice(0, 512) +} + +function clipText(value: string, warnings: Set): string { + const redacted = redactSensitiveText(value, warnings) + if (redacted.length <= MAX_WORKER_TRANSCRIPT_BLOCK_CHARS) { + return redacted + } + warnings.add('Oversized transcript text was clipped.') + return `${redacted.slice(0, MAX_WORKER_TRANSCRIPT_BLOCK_CHARS)}${TRUNCATION_MARKER}` +} + +function boundToolInput( + value: unknown, + budget: { remaining: number; nodes: number }, + depth: number, + warnings: Set +): unknown { + budget.nodes-- + if (budget.nodes < 0 || budget.remaining <= 0) { + warnings.add('Oversized tool input was clipped.') + return '… (truncated)' + } + if (typeof value === 'string') { + const redacted = redactSensitiveText(value, warnings) + const length = Math.min(redacted.length, budget.remaining) + budget.remaining -= length + if (length < redacted.length) { + warnings.add('Oversized tool input was clipped.') + return `${redacted.slice(0, length)}… (truncated)` + } + return redacted + } + if (!value || typeof value !== 'object') { + return value + } + if (depth >= 5) { + warnings.add('Deep tool input was clipped.') + return '… (truncated)' + } + if (Array.isArray(value)) { + const result = value + .slice(0, MAX_WORKER_TRANSCRIPT_INPUT_ITEMS) + .map((item) => boundToolInput(item, budget, depth + 1, warnings)) + if (value.length > MAX_WORKER_TRANSCRIPT_INPUT_ITEMS) { + warnings.add('Oversized tool input was clipped.') + result.push('… (truncated)') + } + return result + } + const result: Record = Object.create(null) + let count = 0 + for (const [rawKey, entry] of Object.entries(value)) { + if (count >= MAX_WORKER_TRANSCRIPT_INPUT_ITEMS || budget.remaining <= 0) { + warnings.add('Oversized tool input was clipped.') + result['…'] = 'truncated' + break + } + const redactedKey = redactSensitiveText(rawKey, warnings) + const key = redactedKey.slice(0, Math.min(redactedKey.length, budget.remaining, 128)) + budget.remaining -= key.length + result[key] = boundToolInput(entry, budget, depth + 1, warnings) + count++ + } + return result +} + +function redactSensitiveText(value: string, warnings: Set): string { + const result = replaceDispatchCapabilities(value) + if (!result.redacted) { + return result.value + } + warnings.add('Dispatch capability tokens were redacted from transcript output.') + return result.value +} + +function replaceDispatchCapabilities(value: string): { value: string; redacted: boolean } { + DISPATCH_CAPABILITY_PATTERN.lastIndex = 0 + const redacted = DISPATCH_CAPABILITY_PATTERN.test(value) + DISPATCH_CAPABILITY_PATTERN.lastIndex = 0 + return { + value: redacted + ? value.replace(DISPATCH_CAPABILITY_PATTERN, DISPATCH_CAPABILITY_REDACTION) + : value, + redacted + } +} diff --git a/src/main/runtime/orchestration/worker-transcript-read.test.ts b/src/main/runtime/orchestration/worker-transcript-read.test.ts new file mode 100644 index 00000000000..0bcceda11f5 --- /dev/null +++ b/src/main/runtime/orchestration/worker-transcript-read.test.ts @@ -0,0 +1,188 @@ +import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { readWorkerTranscript } from './worker-transcript-read' + +function codexMessage(id: string, text: string): string { + return JSON.stringify({ + timestamp: '2026-07-24T12:00:00.000Z', + type: 'event_msg', + payload: { id, type: 'agent_message', message: text } + }) +} + +function grokMessage(id: string, text: string): string { + return JSON.stringify({ + id, + timestamp: '2026-07-24T12:00:00.000Z', + type: 'assistant', + content: text + }) +} + +describe('worker transcript reads', () => { + let directory: string + let transcriptPath: string + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-worker-transcript-')) + transcriptPath = join(directory, 'rollout-session.jsonl') + }) + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }) + }) + + it('returns a bounded tail followed by new messages from the exact file', async () => { + await writeFile( + transcriptPath, + [codexMessage('one', 'first'), codexMessage('two', 'second'), codexMessage('three', 'third')] + .join('\n') + .concat('\n') + ) + + const initial = await readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + limit: 2 + }) + expect(initial).toMatchObject({ + ok: true, + messages: [ + { id: 'two', blocks: [{ type: 'text', text: 'second' }] }, + { id: 'three', blocks: [{ type: 'text', text: 'third' }] } + ], + limited: true + }) + if (!initial.ok) { + throw new Error('Expected the initial transcript page') + } + + await appendFile(transcriptPath, `{malformed}\n${codexMessage('four', 'fourth')}\n`) + const appended = await readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + offset: initial.nextOffset, + limit: 2 + }) + + expect(appended).toMatchObject({ + ok: true, + messages: [{ id: 'four', blocks: [{ type: 'text', text: 'fourth' }] }], + limited: false, + warnings: ['1 malformed transcript record(s) were skipped.'] + }) + }) + + it('reports source changes and unsupported providers without guessing', async () => { + await writeFile(transcriptPath, `${codexMessage('one', 'first')}\n`) + + await expect( + readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + offset: 10_000, + limit: 2 + }) + ).resolves.toMatchObject({ ok: false, reason: 'source_changed' }) + + await expect( + readWorkerTranscript({ + agent: 'gemini', + sessionId: 'session-other', + transcriptPath, + limit: 2 + }) + ).resolves.toEqual({ ok: false, reason: 'provider_unsupported', warnings: [] }) + }) + + it('reuses the Native Chat Grok decoder', async () => { + await writeFile(transcriptPath, `${grokMessage('grok-one', 'Grok structured output')}\n`) + + await expect( + readWorkerTranscript({ + agent: 'grok', + sessionId: 'session-grok', + transcriptPath, + limit: 2 + }) + ).resolves.toMatchObject({ + ok: true, + messages: [ + { + role: 'assistant', + blocks: [{ type: 'text', text: 'Grok structured output' }] + } + ] + }) + }) + + it('makes file-position fallback IDs opaque', async () => { + await writeFile( + transcriptPath, + `${JSON.stringify({ + timestamp: '2026-07-24T12:00:00.000Z', + type: 'event_msg', + payload: { type: 'agent_message', message: 'no provider id' } + })}\n` + ) + + const result = await readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + limit: 2 + }) + + expect(result).toMatchObject({ + ok: true, + messages: [{ id: expect.stringMatching(/^worker-message-/) }], + warnings: ['Transcript-backed message identifiers were made opaque.'] + }) + expect(result.ok && JSON.stringify(result.messages)).not.toContain(transcriptPath) + }) + + it('advances past a record larger than the forward scan window', async () => { + await writeFile(transcriptPath, 'x'.repeat(8 * 1024 * 1024 + 10)) + + const oversized = await readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + offset: 0, + limit: 2 + }) + expect(oversized).toMatchObject({ + ok: true, + messages: [], + limited: true, + warnings: expect.arrayContaining([ + '1 oversized transcript record(s) were skipped.', + 'Transcript scanning stopped at the bounded byte limit; continue with the cursor.' + ]) + }) + if (!oversized.ok) { + throw new Error('Expected the oversized transcript page') + } + expect(oversized.nextOffset).toBe(8 * 1024 * 1024) + + await appendFile(transcriptPath, `\n${codexMessage('after', 'after oversized')}\n`) + const continued = await readWorkerTranscript({ + agent: 'codex', + sessionId: 'session-exact', + transcriptPath, + offset: oversized.nextOffset, + limit: 2 + }) + + expect(continued).toMatchObject({ + ok: true, + messages: [{ id: 'after', blocks: [{ type: 'text', text: 'after oversized' }] }], + limited: false + }) + }) +}) diff --git a/src/main/runtime/orchestration/worker-transcript-read.ts b/src/main/runtime/orchestration/worker-transcript-read.ts new file mode 100644 index 00000000000..d893c35f995 --- /dev/null +++ b/src/main/runtime/orchestration/worker-transcript-read.ts @@ -0,0 +1,263 @@ +import { open, stat } from 'node:fs/promises' +import type { AgentType, NativeChatMessage } from '../../../shared/native-chat-types' +import { resolveNativeChatTranscriptAgent } from '../../../shared/native-chat-agent-support' +import type { OrchestrationWorkerReadFallbackReason } from '../../../shared/orchestration-worker-output' +import { resolveSessionFilePath } from '../../native-chat/session-file-resolver' +import { + MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES, + nativeChatLineDecoderForAgent, + readNativeChatTranscriptTailFile, + type NativeChatLineDecoder +} from '../../native-chat/transcript-tail-reader' +import { transcriptFallbackId } from '../../native-chat/transcript-fallback-id' +import { + boundWorkerTranscriptMessages, + clampWorkerTranscriptLimit +} from './worker-transcript-payload' + +const MAX_FORWARD_TRANSCRIPT_SCAN_BYTES = 8 * 1024 * 1024 + +type WorkerTranscriptReadFailure = { + ok: false + reason: OrchestrationWorkerReadFallbackReason | 'source_changed' + warnings: string[] +} + +type WorkerTranscriptReadSuccess = { + ok: true + filePath: string + messages: NativeChatMessage[] + nextOffset: number + limited: boolean + warnings: string[] +} + +export type WorkerTranscriptReadResult = WorkerTranscriptReadFailure | WorkerTranscriptReadSuccess + +export async function readWorkerTranscript(args: { + agent: AgentType + sessionId: string + transcriptPath?: string + offset?: number + limit?: number +}): Promise { + const transcriptAgent = resolveNativeChatTranscriptAgent(args.agent) + if (!transcriptAgent) { + return { ok: false, reason: 'provider_unsupported', warnings: [] } + } + const decode = nativeChatLineDecoderForAgent(args.agent) + if (!decode) { + return { ok: false, reason: 'provider_unsupported', warnings: [] } + } + let filePath: string | null + try { + filePath = await resolveSessionFilePath(args.agent, args.sessionId, { + transcriptPath: args.transcriptPath + }) + } catch { + return { ok: false, reason: 'transcript_unreadable', warnings: [] } + } + if (!filePath) { + return { ok: false, reason: 'transcript_missing', warnings: [] } + } + const limit = clampWorkerTranscriptLimit(args.limit) + try { + const page = + args.offset === undefined + ? await readInitialPage(filePath, limit, decode) + : await readForwardPage(filePath, args.offset, limit, decode) + if (!page.ok) { + return page + } + const bounded = boundWorkerTranscriptMessages(page.messages, filePath) + return { + ok: true, + filePath, + messages: bounded.messages, + nextOffset: page.nextOffset, + limited: page.limited || bounded.limited, + warnings: [...page.warnings, ...bounded.warnings] + } + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code + return { + ok: false, + reason: + code === 'ENOENT' + ? 'transcript_missing' + : code === 'EACCES' || code === 'EPERM' + ? 'transcript_unreadable' + : 'transcript_parse_failed', + warnings: [] + } + } +} + +async function readInitialPage( + filePath: string, + limit: number, + decode: NativeChatLineDecoder +): Promise { + const page = await readNativeChatTranscriptTailFile(filePath, limit, decode, false) + return { + ok: true, + filePath, + messages: page.messages, + nextOffset: page.consumedTo, + limited: page.hasMore, + warnings: recordWarnings(page.malformedRecordCount, page.oversizedRecordCount) + } +} + +async function readForwardPage( + filePath: string, + startOffset: number, + limit: number, + decode: NativeChatLineDecoder +): Promise { + const fileSize = (await stat(filePath)).size + if (startOffset > fileSize) { + return { ok: false, reason: 'source_changed', warnings: [] } + } + if (startOffset === fileSize) { + return { + ok: true, + filePath, + messages: [], + nextOffset: startOffset, + limited: false, + warnings: [] + } + } + const scanEnd = Math.min(fileSize, startOffset + MAX_FORWARD_TRANSCRIPT_SCAN_BYTES) + const handle = await open(filePath, 'r') + const messages: NativeChatMessage[] = [] + let pendingChunks: Buffer[] = [] + let pendingBytes = 0 + let pendingStart = startOffset + let droppingOversizedRecord = await startsInsideRecord(handle, startOffset) + let malformedRecordCount = 0 + let oversizedRecordCount = 0 + let nextOffset = startOffset + try { + const stream = handle.createReadStream({ + start: startOffset, + end: scanEnd - 1, + autoClose: false + }) + let absoluteOffset = startOffset + for await (const rawChunk of stream) { + const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk) + let segmentStart = 0 + let newline = chunk.indexOf(0x0a) + while (newline >= 0) { + retainPart(chunk.subarray(segmentStart, newline)) + const lineEnd = absoluteOffset + newline + 1 + if (!droppingOversizedRecord) { + decodeLine() + } + resetLine(lineEnd) + nextOffset = lineEnd + if (messages.length >= limit) { + return successfulPage(lineEnd < fileSize) + } + segmentStart = newline + 1 + newline = chunk.indexOf(0x0a, segmentStart) + } + if (segmentStart < chunk.length) { + retainPart(chunk.subarray(segmentStart)) + } + absoluteOffset += chunk.length + } + if (droppingOversizedRecord) { + nextOffset = scanEnd + } + return successfulPage(scanEnd < fileSize, scanEnd < fileSize) + } finally { + await handle.close() + } + + function retainPart(part: Buffer): void { + if (droppingOversizedRecord) { + return + } + pendingBytes += part.length + if (pendingBytes > MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES) { + pendingChunks = [] + droppingOversizedRecord = true + oversizedRecordCount++ + return + } + pendingChunks.push(part) + } + + function resetLine(nextStart: number): void { + pendingChunks = [] + pendingBytes = 0 + droppingOversizedRecord = false + pendingStart = nextStart + } + + function decodeLine(): void { + let line = Buffer.concat(pendingChunks).toString('utf8') + if (line.endsWith('\r')) { + line = line.slice(0, -1) + } + if (!line) { + return + } + try { + JSON.parse(line) + } catch { + malformedRecordCount++ + return + } + const message = decode(line, transcriptFallbackId(filePath, pendingStart)) + if (message) { + messages.push(message) + } + } + + function successfulPage(limited: boolean, scanLimited = false): WorkerTranscriptReadSuccess { + return { + ok: true, + filePath, + messages, + nextOffset, + limited, + warnings: recordWarnings(malformedRecordCount, oversizedRecordCount, scanLimited) + } + } +} + +async function startsInsideRecord( + handle: Awaited>, + offset: number +): Promise { + if (offset === 0) { + return false + } + const previousByte = Buffer.allocUnsafe(1) + const { bytesRead } = await handle.read(previousByte, 0, 1, offset - 1) + return bytesRead === 1 && previousByte[0] !== 0x0a +} + +function recordWarnings( + malformedRecordCount = 0, + oversizedRecordCount = 0, + scanLimited = false +): string[] { + const warnings: string[] = [] + if (malformedRecordCount > 0) { + warnings.push(`${malformedRecordCount} malformed transcript record(s) were skipped.`) + } + if (oversizedRecordCount > 0) { + warnings.push(`${oversizedRecordCount} oversized transcript record(s) were skipped.`) + } + if (scanLimited) { + warnings.push( + 'Transcript scanning stopped at the bounded byte limit; continue with the cursor.' + ) + } + return warnings +} diff --git a/src/main/runtime/recent-pty-output-buffer.test.ts b/src/main/runtime/recent-pty-output-buffer.test.ts index 6c21284e163..42d9bfe30f3 100644 --- a/src/main/runtime/recent-pty-output-buffer.test.ts +++ b/src/main/runtime/recent-pty-output-buffer.test.ts @@ -259,3 +259,31 @@ describe('RecentPtyOutputBuffer', () => { } }) }) + +describe('configurable limit', () => { + // Why: a hardcoded RECENT_PTY_OUTPUT_LIMIT left in one arithmetic branch silently + // under-retained for any caller passing a different limit (the relay passes 100KB). + it('retains exactly the configured limit across many chunks', () => { + const limit = 1000 + const buffer = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit }) + let reference = '' + for (let index = 0; index < 60; index += 1) { + const chunk = `c${index}-`.repeat(9) + buffer.append(chunk) + reference = (reference + chunk).slice(-limit) + } + expect(buffer.read().length).toBe(limit) + expect(buffer.read()).toBe(reference) + }) + + it('rejects a non-positive limit', () => { + expect(() => new RecentPtyOutputBuffer({ limit: 0 })).toThrow('positive integer') + expect(() => new RecentPtyOutputBuffer({ limit: -1 })).toThrow('positive integer') + }) + + it('defaults to RECENT_PTY_OUTPUT_LIMIT', () => { + const buffer = new RecentPtyOutputBuffer({ preserveChunkBoundaries: false }) + buffer.append('z'.repeat(RECENT_PTY_OUTPUT_LIMIT * 2)) + expect(buffer.read().length).toBe(RECENT_PTY_OUTPUT_LIMIT) + }) +}) diff --git a/src/main/runtime/recent-pty-output-buffer.ts b/src/main/runtime/recent-pty-output-buffer.ts index a10559b75c9..dda01e0afdf 100644 --- a/src/main/runtime/recent-pty-output-buffer.ts +++ b/src/main/runtime/recent-pty-output-buffer.ts @@ -26,28 +26,34 @@ export class RecentPtyOutputBuffer { // Original chunk boundaries are owed only to the one-time path-candidate // backfill; compact() ends that obligation and lets read() collapse. private preserveChunkBoundaries: boolean + // Why configurable: the relay retains a different window than the main process. + private readonly limit: number - constructor(options?: { preserveChunkBoundaries?: boolean }) { + constructor(options?: { preserveChunkBoundaries?: boolean; limit?: number }) { this.preserveChunkBoundaries = options?.preserveChunkBoundaries ?? true + this.limit = options?.limit ?? RECENT_PTY_OUTPUT_LIMIT + if (!Number.isSafeInteger(this.limit) || this.limit <= 0) { + throw new Error(`RecentPtyOutputBuffer limit must be a positive integer, got ${this.limit}`) + } } append(data: string): void { if (data.length === 0) { return } - if (data.length >= RECENT_PTY_OUTPUT_LIMIT) { - this.chunks = [data.slice(-RECENT_PTY_OUTPUT_LIMIT)] + if (data.length >= this.limit) { + this.chunks = [data.slice(-this.limit)] this.headIndex = 0 this.headOffset = 0 - this.totalLen = RECENT_PTY_OUTPUT_LIMIT - this.headChunkIsPartial = data.length > RECENT_PTY_OUTPUT_LIMIT + this.totalLen = this.limit + this.headChunkIsPartial = data.length > this.limit return } this.chunks.push(data) this.totalLen += data.length - while (this.totalLen > RECENT_PTY_OUTPUT_LIMIT) { + while (this.totalLen > this.limit) { const headRemaining = this.chunks[this.headIndex].length - this.headOffset - const excess = this.totalLen - RECENT_PTY_OUTPUT_LIMIT + const excess = this.totalLen - this.limit if (headRemaining <= excess) { // Release the dropped chunk's reference; the slot is reclaimed on compaction. this.chunks[this.headIndex] = '' diff --git a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts index 21e7f9aadec..29b1d3596da 100644 --- a/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts +++ b/src/main/runtime/relay/relay-auth-coordinator-recovery.test.ts @@ -39,6 +39,28 @@ describe('RelayAuthCoordinator transient recovery', () => { expect(statuses.at(-1)).toBe('registered') }) + it('does not retry initial relay setup before the server Retry-After window', async () => { + vi.useFakeTimers() + const broker = { closeNow: vi.fn() } + const openBroker = vi + .fn() + .mockRejectedValueOnce(new RelayHttpError('assignment', 503, 30_000)) + .mockResolvedValueOnce(broker) + const coordinator = new RelayAuthCoordinator({ + readContext: async () => context, + openBroker, + onStatus: vi.fn(), + random: () => 0.5 + }) + + coordinator.reconcile() + await vi.advanceTimersByTimeAsync(29_999) + expect(openBroker).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(1) + expect(openBroker).toHaveBeenCalledTimes(2) + expect(coordinator.getActiveBroker()).toBe(broker) + }) + it('retries when cloud-session refresh fails before identity can be read', async () => { vi.useFakeTimers() const broker = { closeNow: vi.fn() } diff --git a/src/main/runtime/relay/relay-auth-coordinator.ts b/src/main/runtime/relay/relay-auth-coordinator.ts index 7f97e33dc41..c2457e91595 100644 --- a/src/main/runtime/relay/relay-auth-coordinator.ts +++ b/src/main/runtime/relay/relay-auth-coordinator.ts @@ -1,5 +1,5 @@ import type { RelayBrokerStatus } from './relay-session-broker' -import { shouldRetryRelayConnectionError } from './relay-http-client' +import { RelayHttpError, shouldRetryRelayConnectionError } from './relay-http-client' export type RelayAuthIdentity = { userId: string @@ -183,13 +183,14 @@ export class RelayAuthCoordinator { if (this.isEpochCurrent(epoch)) { this.options.onStatus('offline') if (shouldRetryRelayConnectionError(error)) { - this.scheduleRetry(epoch, retryIdentityKey) + const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0 + this.scheduleRetry(epoch, retryIdentityKey, retryAfterMs) } } } } - private scheduleRetry(epoch: number, expectedIdentityKey?: string): void { + private scheduleRetry(epoch: number, expectedIdentityKey?: string, retryAfterMs = 0): void { if (this.retryTimer || !this.isEpochCurrent(epoch)) { return } @@ -203,7 +204,7 @@ export class RelayAuthCoordinator { ) this.retryAttempt++ const random = this.options.random ?? Math.random - const delayMs = Math.floor(random() * (capMs + 1)) + const delayMs = Math.max(Math.floor(random() * (capMs + 1)), retryAfterMs) this.retryTimer = setTimeout(() => { this.retryTimer = null if (this.isEpochCurrent(epoch)) { diff --git a/src/main/runtime/relay/relay-drain-retry-schedule.ts b/src/main/runtime/relay/relay-drain-retry-schedule.ts new file mode 100644 index 00000000000..0aa945a7a2b --- /dev/null +++ b/src/main/runtime/relay/relay-drain-retry-schedule.ts @@ -0,0 +1,42 @@ +const RETRY_BASE_MS = 1_000 +const RETRY_MAX_MS = 5 * 60_000 + +export class RelayDrainRetrySchedule { + private timer: ReturnType | null = null + private attempt = 0 + + constructor(private readonly random: () => number = Math.random) {} + + get pending(): boolean { + return this.timer !== null + } + + schedule(retryAfterMs: number, retry: () => void): void { + if (this.timer) { + return + } + const exponent = Math.min(this.attempt, Math.ceil(Math.log2(RETRY_MAX_MS / RETRY_BASE_MS))) + const capMs = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** exponent) + this.attempt++ + const jitterMs = Math.floor(this.random() * (capMs + 1)) + this.timer = setTimeout( + () => { + this.timer = null + retry() + }, + Math.max(jitterMs, retryAfterMs) + ) + } + + reset(): void { + this.attempt = 0 + } + + cancel(): void { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + this.reset() + } +} diff --git a/src/main/runtime/relay/relay-http-client.test.ts b/src/main/runtime/relay/relay-http-client.test.ts index 9a0590adb74..29739a7e480 100644 --- a/src/main/runtime/relay/relay-http-client.test.ts +++ b/src/main/runtime/relay/relay-http-client.test.ts @@ -147,4 +147,38 @@ describe('relay HTTP client', () => { ).rejects.toThrow() expect(cancelledBodies).toBe(2) }) + + it('preserves a bounded Retry-After hint on assignment overload', async () => { + const fetch = vi.fn( + async () => new Response(null, { status: 503, headers: { 'retry-after': '30' } }) + ) + + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + ).rejects.toMatchObject({ + operation: 'assignment', + statusCode: 503, + retryAfterMs: 30_000 + }) + }) + + it('caps an excessive Retry-After hint at five minutes', async () => { + const fetch = vi.fn( + async () => new Response(null, { status: 503, headers: { 'retry-after': '999999' } }) + ) + + await expect( + requestRelayAssignment({ + directorUrl: 'https://relay.example', + relayToken: 'scoped-token', + relayHostId: 'AbCdEf0123_-xyZ9', + fetch + }) + ).rejects.toMatchObject({ retryAfterMs: 5 * 60_000 }) + }) }) diff --git a/src/main/runtime/relay/relay-http-client.ts b/src/main/runtime/relay/relay-http-client.ts index a9b1120d784..394f5eee013 100644 --- a/src/main/runtime/relay/relay-http-client.ts +++ b/src/main/runtime/relay/relay-http-client.ts @@ -4,6 +4,7 @@ import type { E2EEKeypair } from '../e2ee-keypair' import { cancelUnreadResponseBody } from '../../lib/unread-response-body' const RELAY_HTTP_REQUEST_DEADLINE_MS = 15_000 +const RELAY_RETRY_AFTER_MAX_MS = 5 * 60_000 const RelayTokenResponseSchema = z .object({ @@ -33,12 +34,25 @@ export type RelayAssignment = z.infer export class RelayHttpError extends Error { constructor( readonly operation: 'token-exchange' | 'assignment', - readonly statusCode: number + readonly statusCode: number, + readonly retryAfterMs: number | null = null ) { super(`relay_${operation}_failed_${statusCode}`) } } +function relayRetryAfterMs(value: string | null, nowMs = Date.now()): number | null { + if (!value) { + return null + } + const seconds = Number(value) + const delayMs = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(value) - nowMs + if (!Number.isFinite(delayMs) || delayMs <= 0) { + return null + } + return Math.min(RELAY_RETRY_AFTER_MAX_MS, Math.ceil(delayMs)) +} + export function shouldRetryRelayConnectionError(error: unknown): boolean { if (!(error instanceof RelayHttpError)) { return true @@ -87,8 +101,9 @@ export async function exchangeRelayAuthorization(input: { body: JSON.stringify({ relayHostId, hostPublicKeyB64: input.keypair.publicKeyB64 }) }) if (!response.ok) { + const retryAfterMs = relayRetryAfterMs(response.headers.get('retry-after')) await cancelUnreadResponseBody(response) - throw new RelayHttpError('token-exchange', response.status) + throw new RelayHttpError('token-exchange', response.status, retryAfterMs) } const parsed = RelayTokenResponseSchema.safeParse(await response.json()) if (!parsed.success) { @@ -117,8 +132,9 @@ export async function requestRelayAssignment(input: { body: JSON.stringify({ v: 1, relayHostId: input.relayHostId }) }) if (!response.ok) { + const retryAfterMs = relayRetryAfterMs(response.headers.get('retry-after')) await cancelUnreadResponseBody(response) - throw new RelayHttpError('assignment', response.status) + throw new RelayHttpError('assignment', response.status, retryAfterMs) } const parsed = AssignmentResponseSchema.safeParse(await response.json()) if (!parsed.success || !isAllowedRelayOrigin(parsed.data.cellUrl)) { diff --git a/src/main/runtime/relay/relay-origin-pool.ts b/src/main/runtime/relay/relay-origin-pool.ts index 6f0f8715243..6736e272121 100644 --- a/src/main/runtime/relay/relay-origin-pool.ts +++ b/src/main/runtime/relay/relay-origin-pool.ts @@ -4,7 +4,8 @@ import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' import { RelayControlOrigin } from './relay-control-origin' import type { RelayControlClient } from './relay-control-client' import type { RelayDrainMessage } from './relay-control-protocol' -import { requestRelayAssignment, type RelayAssignment } from './relay-http-client' +import { RelayDrainRetrySchedule } from './relay-drain-retry-schedule' +import { RelayHttpError, requestRelayAssignment, type RelayAssignment } from './relay-http-client' import type { RelayBrokerStatus, RelayIdentity } from './relay-session-broker-contract' type RelayOriginPoolOptions = { @@ -34,10 +35,12 @@ export class RelayOriginPool { private relayJwt: string | null = null private rotationTimer: ReturnType | null = null private rotationPromise: Promise | null = null + private readonly drainRetry: RelayDrainRetrySchedule private closed = false constructor(options: RelayOriginPoolOptions) { this.options = options + this.drainRetry = new RelayDrainRetrySchedule(options.random) } get activeAssignment(): RelayAssignment | null { @@ -79,6 +82,7 @@ export class RelayOriginPool { clearTimeout(this.rotationTimer) this.rotationTimer = null } + this.drainRetry.cancel() for (const timer of this.drainTimers.values()) { clearTimeout(timer) } @@ -135,7 +139,7 @@ export class RelayOriginPool { origin.markDraining() this.drainingOrigins.add(origin) this.options.onStatus('draining') - if (!this.rotationPromise) { + if (!this.rotationPromise && !this.drainRetry.pending) { this.rotationPromise = this.resolveDrainTarget(origin, message).finally(() => { this.rotationPromise = null }) @@ -178,11 +182,12 @@ export class RelayOriginPool { await this.activateTarget(origin, assignment, this.relayJwt, message.graceMs) } this.options.onStatus('registered') + this.drainRetry.reset() this.scheduleControlRotation() - } catch { - if (this.isCurrent()) { - const random = this.options.random ?? Math.random - setTimeout(() => this.handleDrain(origin, message), 250 + Math.floor(random() * 751)) + } catch (error) { + if (this.isCurrent() && origin === this.activeOrigin) { + const retryAfterMs = error instanceof RelayHttpError ? (error.retryAfterMs ?? 0) : 0 + this.drainRetry.schedule(retryAfterMs, () => this.handleDrain(origin, message)) } } } diff --git a/src/main/runtime/relay/relay-session-broker.test.ts b/src/main/runtime/relay/relay-session-broker.test.ts index c25092e6784..f4c9e7368a2 100644 --- a/src/main/runtime/relay/relay-session-broker.test.ts +++ b/src/main/runtime/relay/relay-session-broker.test.ts @@ -87,6 +87,7 @@ vi.mock('../rpc/relay-transport', () => ({ })) import { RelaySessionBroker, StaleRelayBrokerError } from './relay-session-broker' +import { RelayHttpError } from './relay-http-client' function deferred() { let resolve!: (value: T) => void @@ -299,6 +300,134 @@ describe('RelaySessionBroker lifecycle ownership', () => { await vi.waitFor(() => expect(onStatus).toHaveBeenLastCalledWith('registered')) expect(broker.endpoint?.cellUrl).toBe('https://relay.example.test') }) + + it('backs off drain resolution failures without duplicate retries or post-close work', async () => { + vi.useFakeTimers() + try { + const ack: RelayHostHelloAckMessage = { + type: 'host-hello-ack', + v: 1, + generation: 1, + controlResumeSecret: 'R'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: [], + pendingConns: [] + } + fakes.controlConnect.mockResolvedValue(ack) + fakes.assign + .mockResolvedValueOnce({ + cellUrl: 'https://relay.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 1_000_000 + }) + .mockRejectedValue(new Error('director_unavailable')) + const broker = await RelaySessionBroker.connect(brokerOptions({ random: () => 0.5 })) + const drain = { + type: 'drain' as const, + graceMs: 5_000, + recovery: 'resolve-director' as const + } + + fakes.controls[0]!.options.onDrain(drain) + await vi.advanceTimersByTimeAsync(0) + expect(fakes.assign).toHaveBeenCalledTimes(2) + fakes.controls[0]!.options.onDrain(drain) + await vi.advanceTimersByTimeAsync(499) + expect(fakes.assign).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(fakes.assign).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(999) + expect(fakes.assign).toHaveBeenCalledTimes(3) + await vi.advanceTimersByTimeAsync(1) + expect(fakes.assign).toHaveBeenCalledTimes(4) + + broker.closeNow() + await vi.advanceTimersByTimeAsync(5 * 60_000) + expect(fakes.assign).toHaveBeenCalledTimes(4) + } finally { + vi.useRealTimers() + } + }) + + it('does not retry drain resolution before the director Retry-After window', async () => { + vi.useFakeTimers() + try { + const ack: RelayHostHelloAckMessage = { + type: 'host-hello-ack', + v: 1, + generation: 1, + controlResumeSecret: 'R'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: [], + pendingConns: [] + } + fakes.controlConnect.mockResolvedValue(ack) + fakes.assign + .mockResolvedValueOnce({ + cellUrl: 'https://relay.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 1_000_000 + }) + .mockRejectedValue(new RelayHttpError('assignment', 503, 30_000)) + const broker = await RelaySessionBroker.connect(brokerOptions({ random: () => 0.5 })) + + fakes.controls[0]!.options.onDrain({ + type: 'drain', + graceMs: 5_000, + recovery: 'resolve-director' + }) + await vi.advanceTimersByTimeAsync(29_999) + expect(fakes.assign).toHaveBeenCalledTimes(2) + await vi.advanceTimersByTimeAsync(1) + expect(fakes.assign).toHaveBeenCalledTimes(3) + broker.closeNow() + } finally { + vi.useRealTimers() + } + }) + + it('recovers through a new origin after the director failure clears', async () => { + vi.useFakeTimers() + try { + const ack: RelayHostHelloAckMessage = { + type: 'host-hello-ack', + v: 1, + generation: 1, + controlResumeSecret: 'R'.repeat(43), + leaseExpiresAt: 1_000_000, + activeConnIds: [], + pendingConns: [] + } + fakes.controlConnect.mockResolvedValue(ack) + fakes.assign + .mockResolvedValueOnce({ + cellUrl: 'https://relay-c1.example.test', + assignmentEpoch: 1, + leaseExpiresAt: 1_000_000 + }) + .mockRejectedValueOnce(new Error('director_unavailable')) + .mockResolvedValueOnce({ + cellUrl: 'https://relay-c2.example.test', + assignmentEpoch: 2, + leaseExpiresAt: 2_000_000 + }) + const broker = await RelaySessionBroker.connect(brokerOptions({ random: () => 0.5 })) + + fakes.controls[0]!.options.onDrain({ + type: 'drain', + graceMs: 5_000, + recovery: 'resolve-director' + }) + await vi.advanceTimersByTimeAsync(499) + expect(broker.endpoint?.cellUrl).toBe('https://relay-c1.example.test') + await vi.advanceTimersByTimeAsync(1) + expect(broker.endpoint?.cellUrl).toBe('https://relay-c2.example.test') + expect(fakes.assign).toHaveBeenCalledTimes(3) + broker.closeNow() + } finally { + vi.useRealTimers() + } + }) }) function brokerBasisIds(broker: RelaySessionBroker): string[] { diff --git a/src/main/runtime/remote-runtime-request-connection.integration.test.ts b/src/main/runtime/remote-runtime-request-connection.integration.test.ts index cb494b2f159..98c295c2548 100644 --- a/src/main/runtime/remote-runtime-request-connection.integration.test.ts +++ b/src/main/runtime/remote-runtime-request-connection.integration.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getDefaultRepoHookSettings } from '../../shared/constants' import type { Repo } from '../../shared/types' import { parsePairingCode } from '../../shared/pairing' @@ -25,6 +25,72 @@ const passthroughDedupe = (_repo: string, _id: string | undefined, run: () => run() describe('remote runtime request connection integration', () => { + it( + 'binds encrypted close-intent capability to the real runtime RPC context', + { timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS }, + async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-close-intent-')) + const refuseUnattributedMobileSessionTabClose = vi.fn().mockResolvedValue({ + closed: true, + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + }) + const closeMobileSessionTab = vi.fn() + const runtime = { + getRuntimeId: () => 'close-intent-runtime-test', + getStartedAt: () => 1, + cleanupSubscriptionsForConnection: () => {}, + cancelMobileDictationForConnection: () => {}, + onClientDisconnected: () => {}, + refuseUnattributedMobileSessionTabClose, + closeMobileSessionTab + } as unknown as OrcaRuntimeService + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + try { + const offer = server.createPairingOffer({ name: 'integration', scope: 'runtime' }) + if (!offer.available) { + throw new Error('pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('invalid pairing') + } + const connection = new RemoteRuntimeRequestConnection(pairing) + try { + await expect( + connection.request( + 'session.tabs.close', + { worktree: 'id:wt-1', tabId: 'tab-1' }, + REMOTE_RUNTIME_REQUEST_TIMEOUT_MS + ) + ).resolves.toMatchObject({ + ok: true, + result: { + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + } + }) + expect(refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1') + expect(closeMobileSessionTab).not.toHaveBeenCalled() + } finally { + connection.close() + } + } finally { + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } + } + ) + it( 'fetches repos through the real E2EE WebSocket runtime', { timeout: REMOTE_RUNTIME_TEST_TIMEOUT_MS }, diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 061ff895a4a..db4c8ac08e1 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -8,6 +8,7 @@ import type { PairingGetEndpointsResult, PairingProvisionRelayParams } from '../../../shared/mobile-relay-credential-contract' +import type { RuntimeCapability } from '../../../shared/protocol-version' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -44,6 +45,9 @@ export type RpcRequest = { authToken: string method: string params?: unknown + orchestrationCapability?: string + orchestrationContractVersion?: number + orchestrationRequestId?: string } export type RpcContext = { @@ -60,6 +64,21 @@ export type RpcContext = { pairedDeviceId?: string // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' + // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. + clientCapabilities?: readonly RuntimeCapability[] + // Why: Dispatch authority rides in the authenticated RPC envelope, never in user payload fields. + orchestrationCapability?: string + // Why: long-lived mutations such as ask can durably expose acceptance before their waiter settles. + recordMutationReceipt?: (receipt: unknown) => void + // Why: worker-start commits this identity with its starting Dispatch so crash recovery always has an inspectable operation. + orchestrationMutation?: { + callerFingerprint: string + requestId: string + method: string + payloadHash: string + } + // Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage. + authenticatedCallerFingerprint?: string pairing?: PairingRpcContext // Why: mobile terminal traffic bypasses JSON streaming; undefined on Unix/socket and non-E2EE WebSocket paths. sendBinary?: (bytes: Uint8Array) => boolean | void diff --git a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts index 6b4bcf3455e..64dc5ee4f7f 100644 --- a/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts +++ b/src/main/runtime/rpc/dispatcher-feature-interactions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { PersistedUIState } from '../../../shared/types' import { getDefaultUIState } from '../../../shared/constants' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' import { ORCA_RUNTIME_RPC_BROWSER_UI_SOURCE, ORCA_RUNTIME_RPC_FEATURE_INTERACTION_SOURCE_KEY @@ -11,7 +12,15 @@ import { defineMethod, defineStreamingMethod, type RpcRequest } from './core' import type { OrcaRuntimeService } from '../orca-runtime' function makeRequest(method: string, params: unknown = {}): RpcRequest { - return { id: 'req-1', authToken: 'tok', method, params } + return { + id: 'req-1', + authToken: 'tok', + method, + params, + ...(method.startsWith('orchestration.') + ? { orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION } + : {}) + } } function makeRuntime(ui: PersistedUIState = getDefaultUIState()): OrcaRuntimeService { diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 5b677088074..71d327c97f6 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -15,9 +15,9 @@ import { type RpcRequest, type RpcResponse } from './core' + import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' import type { FeatureInteractionId } from '../../../shared/feature-interactions' -import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source' import { computerErrorData, errorResponse, @@ -29,6 +29,14 @@ import { import { ALL_RPC_METHODS } from './methods' import { emulatorProbe, emulatorProbeError } from '../../emulator/emulator-probe' import type { OrcaRuntimeService } from '../orca-runtime' +import type { RuntimeCapability } from '../../../shared/protocol-version' +import { + OrchestrationMutationExecutor, + authenticatedCallerFingerprint, + type DurableMutationInvocation +} from './orchestration-mutation-executor' +import { orchestrationMigrationFence } from './orchestration-contract-fence' +import { getRuntimeFeatureInteractionId } from './runtime-feature-interaction' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -38,10 +46,12 @@ export type DispatcherOptions = { export class RpcDispatcher { private readonly runtime: OrcaRuntimeService private readonly registry: RpcRegistry + private readonly orchestrationMutations: OrchestrationMutationExecutor constructor({ runtime, methods = ALL_RPC_METHODS }: DispatcherOptions) { this.runtime = runtime this.registry = buildRegistry(methods) + this.orchestrationMutations = new OrchestrationMutationExecutor(runtime) } async dispatch(request: RpcRequest, options?: { signal?: AbortSignal }): Promise { @@ -56,6 +66,11 @@ export class RpcDispatcher { ) } + const migrationFence = orchestrationMigrationFence(request, meta) + if (migrationFence) { + return migrationFence + } + const parsedParams = this.parseParams(request, method, meta) if (parsedParams.error) { return parsedParams.error @@ -78,10 +93,17 @@ export class RpcDispatcher { emulatorProbe(`rpc ${request.method}`, request.params) } try { - const result = await method.handler(parsedParams.value, { - runtime: this.runtime, - signal: options?.signal - }) + const invoke = (mutation?: DurableMutationInvocation) => + method.handler(parsedParams.value, { + runtime: this.runtime, + signal: options?.signal, + requestId: request.id, + orchestrationCapability: request.orchestrationCapability, + authenticatedCallerFingerprint: authenticatedCallerFingerprint(request), + recordMutationReceipt: mutation?.recordReceipt, + orchestrationMutation: mutation?.identity + }) + const result = await this.orchestrationMutations.run(request, parsedParams.value, invoke) this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) return successResponse(request.id, meta, result) } catch (error) { @@ -104,6 +126,7 @@ export class RpcDispatcher { clientId?: string pairedDeviceId?: string clientKind?: 'mobile' | 'runtime' + clientCapabilities?: readonly RuntimeCapability[] pairing?: PairingRpcContext sendBinary?: (bytes: Uint8Array) => boolean | void registerBinaryStreamHandler?: ( @@ -123,6 +146,12 @@ export class RpcDispatcher { return } + const migrationFence = orchestrationMigrationFence(request, meta) + if (migrationFence) { + reply(JSON.stringify(migrationFence)) + return + } + const parsedParams = this.parseParams(request, method, meta) if (parsedParams.error) { reply(JSON.stringify(parsedParams.error)) @@ -131,18 +160,25 @@ export class RpcDispatcher { if (!isStreamingMethod(method)) { try { - const result = await method.handler(parsedParams.value, { - runtime: this.runtime, - signal: options?.signal, - requestId: request.id, - connectionId: options?.connectionId, - clientId: options?.clientId, - pairedDeviceId: options?.pairedDeviceId, - clientKind: options?.clientKind, - pairing: options?.pairing, - sendBinary: options?.sendBinary, - registerBinaryStreamHandler: options?.registerBinaryStreamHandler - }) + const invoke = (mutation?: DurableMutationInvocation) => + method.handler(parsedParams.value, { + runtime: this.runtime, + signal: options?.signal, + requestId: request.id, + connectionId: options?.connectionId, + clientId: options?.clientId, + pairedDeviceId: options?.pairedDeviceId, + clientKind: options?.clientKind, + clientCapabilities: options?.clientCapabilities, + orchestrationCapability: request.orchestrationCapability, + authenticatedCallerFingerprint: authenticatedCallerFingerprint(request), + recordMutationReceipt: mutation?.recordReceipt, + orchestrationMutation: mutation?.identity, + pairing: options?.pairing, + sendBinary: options?.sendBinary, + registerBinaryStreamHandler: options?.registerBinaryStreamHandler + }) + const result = await this.orchestrationMutations.run(request, parsedParams.value, invoke) this.recordRuntimeFeatureInteraction(request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, meta, result))) } catch (error) { @@ -175,6 +211,7 @@ export class RpcDispatcher { clientId: options?.clientId, pairedDeviceId: options?.pairedDeviceId, clientKind: options?.clientKind, + clientCapabilities: options?.clientCapabilities, pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler @@ -270,50 +307,3 @@ export class RpcDispatcher { } } } - -function getRuntimeFeatureInteractionId( - method: string, - result: unknown, - rawParams?: unknown -): FeatureInteractionId | null { - if (method === 'browser.profileImportFromBrowser') { - return hasBooleanResult(result, 'ok') ? 'cookie-import' : null - } - if (method === 'browser.profileClearDefaultCookies') { - return hasBooleanResult(result, 'cleared') ? 'cookie-import' : null - } - if (method === 'browser.screencast.unsubscribe') { - return null - } - if (method.startsWith('browser.') && isBrowserPaneUiRuntimeRpcParams(rawParams)) { - return null - } - if (method.startsWith('browser.') && !method.startsWith('browser.profile')) { - return 'agent-browser-use' - } - if (method.startsWith('emulator.')) { - // Emulator commands are allowed from terminal/CLI (workspace-scoped, like other automation). - // Return null to indicate no special feature-interaction restriction (or add 'emulator-use' later). - return null - } - if (method === 'computer.permissions') { - return 'computer-use-setup' - } - if ( - method.startsWith('computer.') && - method !== 'computer.capabilities' && - method !== 'computer.permissionsStatus' - ) { - return 'computer-use' - } - if (method.startsWith('orchestration.')) { - return 'agent-orchestration' - } - return null -} - -function hasBooleanResult(value: unknown, key: string): boolean { - return ( - value !== null && typeof value === 'object' && (value as Record)[key] === true - ) -} diff --git a/src/main/runtime/rpc/e2ee-channel-v2.test.ts b/src/main/runtime/rpc/e2ee-channel-v2.test.ts index 00fd0093c4c..f9e602ced41 100644 --- a/src/main/runtime/rpc/e2ee-channel-v2.test.ts +++ b/src/main/runtime/rpc/e2ee-channel-v2.test.ts @@ -156,7 +156,7 @@ describe('E2EEChannel v2', () => { }) }) - it('rejects legacy downgrade and injected auth metadata when v2 is required', () => { + it('rejects legacy downgrade and runtime-only capability metadata when mobile v2 is required', () => { const legacy = setup() legacy.channel.handleRawMessage( JSON.stringify({ type: 'e2ee_hello', publicKeyB64: 'legacy-key' }) @@ -173,13 +173,14 @@ describe('E2EEChannel v2', () => { v: 2, transcriptHashB64, deviceToken: 'valid-token', - relayDeviceId: 'injected' + clientCapabilities: ['session-tabs.close-intent.v1'] }), schedule, 0n ) ) expect(ctx.resolveAuthenticatedDevice).not.toHaveBeenCalled() + expect(ctx.onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_auth') }) it('rejects a captured auth frame replayed onto a fresh desktop nonce', () => { diff --git a/src/main/runtime/rpc/e2ee-channel.test.ts b/src/main/runtime/rpc/e2ee-channel.test.ts index 8ca3c2062cf..8602263400d 100644 --- a/src/main/runtime/rpc/e2ee-channel.test.ts +++ b/src/main/runtime/rpc/e2ee-channel.test.ts @@ -7,6 +7,7 @@ import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { REMOTE_RUNTIME_JSON_STRUCTURE_LIMITS } from '../../../shared/remote-runtime-request-frames' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' function publicKeyToBase64(key: Uint8Array): string { return Buffer.from(key).toString('base64') @@ -101,6 +102,35 @@ describe('E2EEChannel', () => { expect(JSON.parse(ctx.ws.sent[0]!)).toEqual({ type: 'e2ee_ready' }) }) + it('binds runtime capabilities to encrypted authenticated metadata', () => { + const ctx = setup({ + resolveAuthenticatedDevice: (token) => + token === 'valid-token' + ? { deviceId: 'device-1', deviceToken: token, scope: 'runtime' } + : null + }) + ctx.channel.handleRawMessage( + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: publicKeyToBase64(ctx.clientKeys.publicKey) + }) + ) + const sharedKey = deriveSharedKey(ctx.clientKeys.secretKey, ctx.serverKeys.publicKey) + ctx.channel.handleRawMessage( + encrypt( + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: 'valid-token', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + }), + sharedKey + ) + ) + + expect(ctx.channel.clientCapabilities).toEqual([SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY]) + expect(ctx.onReady).toHaveBeenCalledOnce() + }) + it('rejects invalid encrypted token', () => { const ctx = setup() ctx.channel.handleRawMessage( diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index 95ae6f8d8cc..1cef6a10213 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -16,6 +16,8 @@ import { import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames' import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner' +import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' +import type { RuntimeCapability } from '../../../shared/protocol-version' const HANDSHAKE_TIMEOUT_MS = 10_000 const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5 @@ -62,6 +64,7 @@ export class E2EEChannel { deviceToken: string | null = null authenticatedDevice: E2EEAuthenticatedDevice | null = null + clientCapabilities: readonly RuntimeCapability[] = [] constructor(ws: WebSocket, options: E2EEChannelOptions) { this.ws = ws @@ -246,6 +249,7 @@ export class E2EEChannel { } const authenticatedDevice = authentication.device + this.clientCapabilities = parseRuntimeClientCapabilities(authentication.auth.clientCapabilities) this.deviceToken = authenticatedDevice.deviceToken this.authenticatedDevice = authenticatedDevice this.state = 'ready' diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 0e4519656cb..ddd0541c57b 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -30,6 +30,18 @@ describe('mapRuntimeError', () => { }) }) + it.each(['remote_runtime_unavailable', 'runtime_timeout', 'invalid_runtime_response'])( + 'preserves structured remote transport failure %s', + (code) => { + const error = Object.assign(new Error(`Remote transport failed: ${code}`), { code }) + + expect(mapRuntimeError('req_1', { runtimeId: 'runtime-1' }, error)).toMatchObject({ + ok: false, + error: { code, message: `Remote transport failed: ${code}` } + }) + } + ) + it.each([ ['window_not_focused', 'keyboard input requires focus', 'restore-window'], ['permission_denied', 'missing DBUS_SESSION_BUS_ADDRESS', 'permissions'], diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index 39190c6dfcb..d76b0e851c2 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -60,7 +60,41 @@ const RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ const COMPUTER_PASSTHROUGH_CODES: ReadonlySet = new Set(Object.values(COMPUTER_ERROR_CODES)) const LINEAR_PASSTHROUGH_CODES: ReadonlySet = new Set(LINEAR_ERROR_CODES) const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ - 'worktree_id_requires_full_path' + 'worktree_id_requires_full_path', + 'run_not_found', + 'run_required', + 'stable_pane_required', + 'consumer_fenced', + 'task_not_found', + 'task_not_startable', + 'dispatch_not_found', + 'dispatch_run_mismatch', + 'dispatch_inactive', + 'worker_identity_changed', + 'cursor_invalid', + 'cursor_dispatch_mismatch', + 'source_changed', + 'transcript_required', + 'server_required', + 'worktree_not_found_on_server', + 'resource_server_mismatch', + 'peer_changed', + 'remote_runtime_unavailable', + 'runtime_timeout', + 'invalid_runtime_response', + 'capability_unsupported', + 'relay_quota_exceeded', + 'dispatch_capability_invalid', + 'agent_unconfigured', + 'terminal_worktree_mismatch', + 'request_mismatch', + 'orchestration_migration_required', + 'operation_unknown', + 'question_not_found', + 'answer_conflict', + 'stale_delivery', + 'waiter_exists', + 'invalid_argument' ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/methods/client-ui-schemas.ts b/src/main/runtime/rpc/methods/client-ui-schemas.ts index 7265a1ee806..6b8862b1ec2 100644 --- a/src/main/runtime/rpc/methods/client-ui-schemas.ts +++ b/src/main/runtime/rpc/methods/client-ui-schemas.ts @@ -13,7 +13,9 @@ import { isTaskProvider } from '../../../../shared/task-providers' import { normalizeDisabledTuiAgents } from '../../../../shared/tui-agent-selection' import { normalizePRBotAuthorOverrides } from '../../../../shared/pr-bot-author-overrides' import { normalizeWorktreeCardProperties } from '../../../../shared/worktree-card-properties' -import type { TaskProvider } from '../../../../shared/types' +import type { PersistedUIState, TaskProvider } from '../../../../shared/types' +import { TaskResumeState } from './task-resume-state-schema' +import type { AssertNoMissingKeys } from './ui-state-schema-parity' const NullableString = z.string().nullable() const StringArray = z.array(z.string()) @@ -59,16 +61,6 @@ const WorkspaceStatusDefinition = z.object({ color: z.string().optional(), icon: z.string().optional() }) -const TaskResumeState = z - .object({ - githubMode: z.enum(['items', 'project']).optional(), - githubItemsPreset: z.string().nullable().optional(), - githubItemsQuery: z.string().optional(), - githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), - linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), - linearQuery: z.string().optional() - }) - .strict() const WorkspaceCleanupDismissal = z .object({ worktreeId: z.string(), @@ -173,6 +165,21 @@ export const UiUpdate = z .object({ lastActiveRepoId: NullableString.optional(), lastActiveWorktreeId: NullableString.optional(), + // Why: App.tsx persists this on every top-level view switch (#9002). Desktop + // hydration ignores it on 'sync' broadcasts, so accepting it cannot yank a + // paired window's current view — it only restores the view on next startup. + activeView: z + .enum([ + 'terminal', + 'settings', + 'tasks', + 'activity', + 'automations', + 'space', + 'skills', + 'mobile' + ]) + .optional(), sidebarWidth: z.number().finite().optional(), rightSidebarOpen: z.boolean().optional(), rightSidebarTab: z @@ -197,6 +204,9 @@ export const UiUpdate = z .optional(), hideDefaultBranchWorkspace: z.boolean().optional(), hideAutomationGeneratedWorkspaces: z.boolean().optional(), + // Why: rides App.tsx's debounced writer, so omitting it rejected that entire + // payload (sidebar widths, filters, agent acks) for every paired client. + showDotfilesByWorktree: z.record(z.string(), z.boolean()).optional(), hideCliCreatedWorkspaces: z.boolean().optional(), hideDetachedHeadWorkspaces: z.boolean().optional(), filterRepoIds: StringArray.optional(), @@ -253,6 +263,14 @@ export const UiUpdate = z _inlineAgentsDefaultedForAllUsers: z.boolean().optional(), trustedOrcaHooks: z.record(z.string(), z.unknown()).optional(), setupScriptPromptDismissedRepoIds: StringArray.optional(), + // Why: one-shot dismissals the renderer writes through ui.set; each was a + // whole-payload rejection for paired clients while unlisted. + setupGuideSidebarDismissed: z.boolean().optional(), + setupGuideBrowserMilestoneMigrated: z.boolean().optional(), + setupGuideBrowserMilestoneLegacyComplete: z.boolean().optional(), + browserImportHintHidden: z.boolean().optional(), + mobileEmulatorTabIntroDismissed: z.boolean().optional(), + mobileEmulatorAgentSetupDismissed: z.boolean().optional(), projectOrderManualDefaultNoticeDismissed: z.boolean().optional(), usagePercentageDisplayChangeNoticeDismissed: z.boolean().optional(), usageEmptyStateDismissed: z.boolean().optional(), @@ -273,3 +291,23 @@ export const UiUpdate = z }) .strict() .default({}) + +// Why: state only the main process ever writes (store.updateUI, star-nag's own +// IPC, window lifecycle). Clients never send these, so keeping them out of the +// strict schema is deliberate — but it must stay deliberate rather than +// forgotten, which is what the parity assertion below enforces. +type MainOwnedUIState = + | 'trayMinimizeNoticeShown' + | 'dashboardPopoutBounds' + | '_expandedWorktreeCardPropertiesDefaulted' + | 'starNagBaselineAgents' + | 'starNagAppVersion' + | 'starNagNextThreshold' + | 'starNagCompleted' + | 'starNagDeferredUntil' + | 'starNagAgentValueMomentAppVersion' +const _uiUpdateParity: AssertNoMissingKeys< + Omit, + z.infer +> = true +void _uiUpdateParity diff --git a/src/main/runtime/rpc/methods/client-ui.test.ts b/src/main/runtime/rpc/methods/client-ui.test.ts index 4accec2b0e3..03b0f698756 100644 --- a/src/main/runtime/rpc/methods/client-ui.test.ts +++ b/src/main/runtime/rpc/methods/client-ui.test.ts @@ -437,7 +437,16 @@ describe('client UI RPC methods', () => { githubItemsQuery: 'is:open', githubProjectHiddenFieldIdsByView: { 'project-1:view-1': ['field-1'] - } + }, + linearMode: 'projects', + linearContext: { + kind: 'project', + id: 'project-9', + workspaceId: 'workspace-1', + model: 'project' + }, + jiraPreset: 'assigned', + jiraQuery: 'ENG' }, workspaceCleanup: { dismissals: { @@ -479,7 +488,16 @@ describe('client UI RPC methods', () => { githubItemsQuery: 'is:open', githubProjectHiddenFieldIdsByView: { 'project-1:view-1': ['field-1'] - } + }, + linearMode: 'projects', + linearContext: { + kind: 'project', + id: 'project-9', + workspaceId: 'workspace-1', + model: 'project' + }, + jiraPreset: 'assigned', + jiraQuery: 'ENG' }, workspaceCleanup: { dismissals: { @@ -510,6 +528,80 @@ describe('client UI RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ui: updated } }) }) + // Why one case per field: the schema is strict, so a single unlisted key makes + // the dispatcher reject the ENTIRE ui.set payload with invalid_argument instead + // of stripping it. A combined payload would pass as soon as any one field were + // restored, hiding the rest of the drift. + it.each([ + ['taskResumeState.linearMode', { taskResumeState: { linearMode: 'projects' } }], + [ + 'taskResumeState.linearContext', + { + taskResumeState: { + linearContext: { kind: 'project', id: 'project-9', workspaceId: 'workspace-1' } + } + } + ], + ['taskResumeState.jiraPreset', { taskResumeState: { jiraPreset: 'assigned' } }], + ['taskResumeState.jiraQuery', { taskResumeState: { jiraQuery: 'ENG' } }], + ['activeView', { activeView: 'tasks' }], + ['showDotfilesByWorktree', { showDotfilesByWorktree: { 'repo::/worktree': true } }], + ['setupGuideSidebarDismissed', { setupGuideSidebarDismissed: true }], + ['setupGuideBrowserMilestoneMigrated', { setupGuideBrowserMilestoneMigrated: true }], + [ + 'setupGuideBrowserMilestoneLegacyComplete', + { setupGuideBrowserMilestoneLegacyComplete: true } + ], + ['browserImportHintHidden', { browserImportHintHidden: true }], + ['mobileEmulatorTabIntroDismissed', { mobileEmulatorTabIntroDismissed: true }], + ['mobileEmulatorAgentSetupDismissed', { mobileEmulatorAgentSetupDismissed: true }] + ])('accepts %s, which the renderer persists through ui.set', async (_label, payload) => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + + const response = await dispatcher.dispatch(makeRequest('ui.set', payload)) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith(payload) + }) + + it('accepts the whole debounced App writer payload', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + updateUIState: vi.fn(() => getDefaultUIState()) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: CLIENT_UI_METHODS }) + // Mirrors App.tsx's 150ms writer: one unlisted key here dropped every other + // preference in the same call for paired web/SSH/relay clients. + const payload = { + sidebarWidth: 280, + rightSidebarOpen: true, + rightSidebarTab: 'explorer', + rightSidebarExplorerView: 'files', + rightSidebarWidth: 320, + markdownTocPanelWidth: 200, + groupBy: 'repo', + sortBy: 'smart', + projectOrderBy: 'manual', + showActiveOnly: false, + hideSleepingWorkspaces: false, + showSleepingWorkspaces: true, + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + showDotfilesByWorktree: { 'repo::/worktree': true }, + filterRepoIds: ['repo-1'], + acknowledgedAgentsByPaneKey: { 'pane-1': 123 } + } + + const response = await dispatcher.dispatch(makeRequest('ui.set', payload)) + + expect(response).toMatchObject({ ok: true }) + expect(runtime.updateUIState).toHaveBeenCalledWith(payload) + }) + it('records a feature interaction through the runtime host', async () => { const updated: PersistedUIState = { ...getDefaultUIState(), diff --git a/src/main/runtime/rpc/methods/diagnostics.test.ts b/src/main/runtime/rpc/methods/diagnostics.test.ts index 41278554f65..82fc1fa9b75 100644 --- a/src/main/runtime/rpc/methods/diagnostics.test.ts +++ b/src/main/runtime/rpc/methods/diagnostics.test.ts @@ -23,11 +23,14 @@ describe('diagnostics RPC methods', () => { host: { totalMemory: 4096, freeMemory: 1024, + availableMemory: 1024, + availableMemorySource: 'free-memory', usedMemory: 3072, memoryUsagePercent: 75, cpuCoreCount: 8, loadAverage1m: 1.25 }, + processMemoryMetric: 'rss', totalCpu: 1, totalMemory: 1024, collectedAt: 123 diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index 537e7ce97f5..df9f808006c 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -31,6 +31,7 @@ import { SPEECH_METHODS } from './speech' import { CLIENT_UI_METHODS } from './client-ui' import { CLIENT_EVENT_METHODS } from './client-events' import { WORKSPACE_PORT_METHODS } from './workspace-ports' +import { PLUGIN_METHODS } from './plugins' import { SKILL_METHODS } from './skills' import { CLIPBOARD_METHODS } from './clipboard' import { HOST_CAPABILITY_METHODS } from './host-capabilities' @@ -74,6 +75,7 @@ export const ALL_RPC_METHODS: readonly RpcAnyMethod[] = [ ...SSH_METHODS, ...SPEECH_METHODS, ...WORKSPACE_PORT_METHODS, + ...PLUGIN_METHODS, ...SKILL_METHODS, ...CLIPBOARD_METHODS, ...HOST_CAPABILITY_METHODS, diff --git a/src/main/runtime/rpc/methods/jira.test.ts b/src/main/runtime/rpc/methods/jira.test.ts index 8bf82eab593..ce02c6ec764 100644 --- a/src/main/runtime/rpc/methods/jira.test.ts +++ b/src/main/runtime/rpc/methods/jira.test.ts @@ -117,6 +117,29 @@ describe('jira RPC methods', () => { expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1') }) + it('streams Jira image-bearing payloads in bounded JSON chunks', async () => { + const description = `![shot](data:image/png;base64,${'a'.repeat(300_000)})` + const runtime = { + getRuntimeId: () => 'test-runtime', + jiraGetIssue: vi.fn().mockResolvedValue({ key: 'ABC-3', description }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: JIRA_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('jira.getIssueStream', { key: 'ABC-3', siteId: 'site-1' }), + (response) => replies.push(response) + ) + + const messages = replies.map( + (response) => (JSON.parse(response) as { result: { type: string; content?: string } }).result + ) + expect(messages.at(-1)).toEqual({ type: 'end' }) + expect(messages.filter((message) => message.type === 'chunk')).toHaveLength(2) + const payload = messages.map((message) => message.content ?? '').join('') + expect(JSON.parse(payload)).toEqual({ key: 'ABC-3', description }) + }) + it('routes Jira metadata requests to the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/jira.ts b/src/main/runtime/rpc/methods/jira.ts index a18eb2d02d3..2b9324d8501 100644 --- a/src/main/runtime/rpc/methods/jira.ts +++ b/src/main/runtime/rpc/methods/jira.ts @@ -1,5 +1,9 @@ import { z } from 'zod' -import { defineMethod, type RpcMethod } from '../core' +import { + JIRA_PAYLOAD_CHUNK_CHARS, + JIRA_PAYLOAD_MAX_CHARS +} from '../../../../shared/jira-payload-stream' +import { defineMethod, defineStreamingMethod, type RpcAnyMethod } from '../core' import { OptionalFiniteNumber, OptionalPlainString, @@ -95,7 +99,20 @@ const ProjectStatusOrder = z.object({ siteId: OptionalString }) -export const JIRA_METHODS: RpcMethod[] = [ +function emitJiraPayload(value: unknown, emit: (result: unknown) => void): void { + const payload = JSON.stringify(value) + if (payload.length > JIRA_PAYLOAD_MAX_CHARS) { + throw new Error('Jira payload exceeded the transfer limit.') + } + // Why: remote runtime WebSocket messages are capped at 1 MiB; chunking keeps + // authenticated inline images usable over SSH without raising that safety cap. + for (let offset = 0; offset < payload.length; offset += JIRA_PAYLOAD_CHUNK_CHARS) { + emit({ type: 'chunk', content: payload.slice(offset, offset + JIRA_PAYLOAD_CHUNK_CHARS) }) + } + emit({ type: 'end' }) +} + +export const JIRA_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'jira.connect', params: Connect, @@ -144,6 +161,13 @@ export const JIRA_METHODS: RpcMethod[] = [ params: IssueKey, handler: async (params, { runtime }) => runtime.jiraGetIssue(params.key.trim(), params.siteId) }), + defineStreamingMethod({ + name: 'jira.getIssueStream', + params: IssueKey, + handler: async (params, { runtime }, emit) => { + emitJiraPayload(await runtime.jiraGetIssue(params.key.trim(), params.siteId), emit) + } + }), defineMethod({ name: 'jira.createIssue', params: CreateIssue, @@ -175,6 +199,13 @@ export const JIRA_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.jiraIssueComments(params.key.trim(), params.siteId) }), + defineStreamingMethod({ + name: 'jira.issueCommentsStream', + params: IssueKey, + handler: async (params, { runtime }, emit) => { + emitJiraPayload(await runtime.jiraIssueComments(params.key.trim(), params.siteId), emit) + } + }), defineMethod({ name: 'jira.listProjects', params: SiteSelection, diff --git a/src/main/runtime/rpc/methods/orchestration-federated-message-targeting.test.ts b/src/main/runtime/rpc/methods/orchestration-federated-message-targeting.test.ts new file mode 100644 index 00000000000..bf1a9223f82 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-message-targeting.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' + +describe('orchestration federated message targeting', () => { + let db: OrchestrationDb | undefined + let runtime: OrcaRuntimeService | undefined + + afterEach(() => { + runtime?.stopOrchestrationFederationRelay() + db?.close() + }) + + it('rejects explicit send and ask targets without enqueueing a relay', async () => { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const paneKey = 'tab_worker:leaf_worker' + const processIncarnation = 'worker_epoch:pty:1' + const dispatchId = 'ctx_remote_targeting' + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue(paneKey) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue(processIncarnation) + db.createRemoteDispatchAttachment({ + dispatchId, + taskId: 'task_remote_targeting', + homePeerFingerprint: 'home_peer', + protocolVersion: 1, + runtimeEpoch: runtime.getRuntimeId(), + mutationReceipt: { + callerFingerprint: 'home_peer', + requestId: 'attach_request', + method: 'orchestration.federationAttachStart', + payloadHash: 'attach_payload' + } + }) + const capability = db.prepareRemoteAttachmentAuthority({ + dispatchId, + paneKey, + processIncarnation, + worktreeId: 'repo::remote-worktree', + terminalHandle: 'term_remote_worker', + setupState: 'not_applicable', + effects: [] + }) + db.markRemoteAttachmentReady(dispatchId) + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const requests: RpcRequest[] = [ + request('send_to', capability, 'orchestration.send', { + from: 'term_remote_worker', + to: 'run:explicit', + subject: 'Wrong explicit target' + }), + request('send_run', capability, 'orchestration.send', { + from: 'term_remote_worker', + run: 'run_explicit', + subject: 'Wrong explicit Run' + }), + request('ask_to', capability, 'orchestration.ask', { + from: 'term_remote_worker', + to: 'run:explicit', + question: 'Wrong explicit target?' + }), + request('ask_run', capability, 'orchestration.ask', { + from: 'term_remote_worker', + run: 'run_explicit', + question: 'Wrong explicit Run?' + }) + ] + + for (const item of requests) { + await expect(dispatcher.dispatch(item)).resolves.toMatchObject({ + ok: false, + error: { + code: 'invalid_argument', + message: 'Federated Dispatch messages route to their Run home; omit --to and --run.' + } + }) + } + expect( + db.listFederationRelay({ dispatchId, direction: 'to_home', afterSequence: 0 }) + ).toHaveLength(0) + }) +}) + +function request( + id: string, + capability: string, + method: 'orchestration.send' | 'orchestration.ask', + params: Record +): RpcRequest { + return { + id: `rpc_${id}`, + authToken: 'worker-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `request_${id}`, + orchestrationCapability: capability, + method, + params + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts new file mode 100644 index 00000000000..538dadc96d0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federated-worker-start.ts @@ -0,0 +1,292 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { RuntimeStatus } from '../../../../shared/runtime-types' +import { + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION, + ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { orchestrationMigrationData } from '../../../../shared/orchestration-rpc-contract' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { WorkerStartInput } from './orchestration-worker-start-schema' + +export async function startFederatedWorker(args: { + params: WorkerStartInput + runtime: OrcaRuntimeService + db: OrchestrationDb + runId: string + task: { id: string; spec: string; status: string } + orchestrationMutation?: { + callerFingerprint: string + requestId: string + method: string + payloadHash: string + } +}): Promise { + const { params, runtime, db, task, runId, orchestrationMutation } = args + if (!orchestrationMutation) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote worker-start requires a durable retry request.' + ) + } + const worktree = params.worktree ?? 'current' + if (worktree === 'current' || worktree === 'new-child') { + throw new OrchestrationError( + 'invalid_argument', + '--on requires an exact remote worktree selector or new-top-level.' + ) + } + const createsWorktree = worktree === 'new-top-level' + validateRemoteWorkerStart(params, createsWorktree) + + const server = runtime.resolveOrchestrationWorkerServer(params.on as string) + const status = (await runtime.callOrchestrationWorkerServer( + server.environmentId, + 'status.get', + undefined, + params.timeoutMs + )) as RuntimeStatus + if (!status.capabilities?.includes(ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY)) { + throw new OrchestrationError( + 'orchestration_migration_required', + `Connected server ${server.name} does not support the current orchestration contract. No effects were applied.`, + orchestrationMigrationData('runtime_capability_missing') + ) + } + if (!status.capabilities?.includes(ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY)) { + throw new OrchestrationError( + 'capability_unsupported', + `Connected server ${server.name} does not support orchestration federation.` + ) + } + const federationProtocolVersion = status.capabilities?.includes( + ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY + ) + ? ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION + : 1 + + const setupDecision = createsWorktree ? (params.setup ?? 'run') : 'not_applicable' + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + retryOf: params.retryOf, + startOptions: { + on: server.environmentId, + serverName: server.name, + worktree, + name: params.name ?? null, + repo: params.repo ?? null, + baseBranch: params.baseBranch ?? null, + terminal: params.terminal ?? null, + agent: params.agent ?? null, + timeoutMs: params.timeoutMs ?? 60_000, + setup: setupDecision, + setupSource: createsWorktree + ? params.setup + ? 'explicit_request' + : 'orchestration_default' + : 'existing_worktree' + }, + runtimeEpoch: runtime.getRuntimeId(), + mutationReceipt: orchestrationMutation, + federation: { + environmentId: server.environmentId, + environmentName: server.name, + peerFingerprint: server.peerFingerprint, + protocolVersion: federationProtocolVersion + } + }) + db.recordWorkerStage({ dispatchId: started.dispatch.id, stage: 'remote_attach_requested' }) + try { + const remote = (await runtime.callOrchestrationWorkerServer( + server.environmentId, + 'orchestration.federationAttachStart', + { + dispatchId: started.dispatch.id, + taskId: task.id, + taskSpec: task.spec, + protocolVersion: federationProtocolVersion, + worktree, + name: params.name, + repo: params.repo, + baseBranch: params.baseBranch, + displayName: params.displayName, + comment: params.comment, + setup: createsWorktree ? (params.setup ?? 'run') : undefined, + setupSource: createsWorktree + ? params.setup + ? 'explicit_request' + : 'orchestration_default' + : undefined, + terminal: params.terminal, + agent: params.agent, + timeoutMs: params.timeoutMs, + devMode: params.devMode + }, + (params.timeoutMs ?? 60_000) + 15_000, + { orchestrationRequestId: orchestrationMutation.requestId } + )) as RemoteStartReceipt + if (remote.dispatchId !== started.dispatch.id) { + throw new OrchestrationError( + 'resource_server_mismatch', + 'The worker server returned a different Dispatch attachment.' + ) + } + if (remote.state === 'ready' && remote.worktreeId && remote.terminalHandle) { + db.updateFederatedDispatchResources({ + dispatchId: started.dispatch.id, + remoteRuntimeEpoch: remote.runtimeEpoch, + worktreeId: remote.worktreeId, + terminalHandle: remote.terminalHandle + }) + db.recordWorkerStage({ + dispatchId: started.dispatch.id, + stage: 'remote_input_accepted', + worktreeId: remote.worktreeId, + terminalHandle: remote.terminalHandle, + setupState: remote.setup?.state, + effects: remote.effects, + residualResources: remote.residualResources + }) + const readyWorker = db.markWorkerDispatchReady(started.dispatch.id) + runtime.ensureOrchestrationFederationRelay(runId) + return { + runId, + taskId: task.id, + dispatchId: started.dispatch.id, + state: 'ready', + stage: readyWorker.stage, + server: { environmentId: server.environmentId, name: server.name }, + setup: remote.setup, + timeoutMs: params.timeoutMs ?? 60_000, + effects: remote.effects ?? [], + residualResources: remote.residualResources ?? [] + } + } + if (remote.state === 'outcome_unknown') { + const worker = db.markWorkerStartUnknown( + started.dispatch.id, + remote.failedStage ?? 'remote_attach', + remote.lastError ?? 'The worker server reported an unknown start outcome.' + ) + return federatedUnknownReceipt(worker, task.id, server.name) + } + const worker = db.failWorkerStart( + started.dispatch.id, + remote.failedStage ?? 'remote_attach', + remote.lastError ?? `The worker server returned ${remote.state}.` + ) + return { + runId, + taskId: task.id, + dispatchId: started.dispatch.id, + state: worker.state, + stage: worker.stage, + server: { environmentId: server.environmentId, name: server.name }, + failedStage: worker.stage, + lastError: worker.last_error, + setup: remote.setup, + effects: remote.effects ?? [], + residualResources: remote.residualResources ?? [] + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + if (error instanceof OrchestrationError && isKnownRemoteStartFailure(error.code)) { + const worker = db.failWorkerStart(started.dispatch.id, 'remote_attach', reason) + return { + runId, + taskId: task.id, + dispatchId: started.dispatch.id, + state: worker.state, + stage: worker.stage, + server: { environmentId: server.environmentId, name: server.name }, + failedStage: worker.stage, + lastError: worker.last_error, + effects: [], + residualResources: [] + } + } + const worker = db.markWorkerStartUnknown(started.dispatch.id, 'remote_attach', reason) + return federatedUnknownReceipt(worker, task.id, server.name) + } +} + +type RemoteStartReceipt = { + dispatchId: string + state: string + runtimeEpoch: string + worktreeId?: string + terminalHandle?: string + setup?: { state: string } + effects?: unknown[] + residualResources?: unknown[] + failedStage?: string + lastError?: string +} + +function validateRemoteWorkerStart(params: WorkerStartInput, createsWorktree: boolean): void { + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote new-top-level requires --name and an explicit --repo from remote discovery.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (!params.terminal && (!params.agent || !isTuiAgent(params.agent))) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A configured --agent is required when remote worker-start creates a terminal.' + ) + } +} + +function isKnownRemoteStartFailure(code: string): boolean { + return [ + 'invalid_argument', + 'agent_unconfigured', + 'worktree_not_found_on_server', + 'terminal_worktree_mismatch', + 'capability_unsupported' + ].includes(code) +} + +function federatedUnknownReceipt( + worker: { dispatch_id: string; state: string; stage: string; last_error: string | null }, + taskId: string, + serverName: string +): unknown { + return { + taskId, + dispatchId: worker.dispatch_id, + state: 'outcome_unknown', + stage: worker.stage, + server: { name: serverName }, + failedStage: worker.stage, + lastError: worker.last_error, + effects: [], + residualResources: [], + nextCommands: [ + `orca orchestration worker-show --dispatch ${worker.dispatch_id} --json`, + `orca orchestration worker-abandon --dispatch ${worker.dispatch_id} --json` + ] + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation-control-mail.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-control-mail.test.ts new file mode 100644 index 00000000000..8a5111047cb --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-control-mail.test.ts @@ -0,0 +1,345 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { authenticatedCallerFingerprint } from '../orchestration-mutation-executor' +import { ORCHESTRATION_METHODS } from './orchestration' + +describe('orchestration federation control mail', () => { + const homeToken = 'run-home-device-token' + const workerToken = 'worker-local-token' + const workerPeerFingerprint = 'worker-peer' + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const workerPaneKey = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + const processIncarnation = 'worker-runtime:pty:1' + let homeDb: OrchestrationDb + let workerDb: OrchestrationDb + let homeRuntime: OrcaRuntimeService + let workerRuntime: OrcaRuntimeService + let homeDispatcher: RpcDispatcher + let workerDispatcher: RpcDispatcher + let dispatchId: string + let runId: string + + beforeEach(() => { + workerDb = new OrchestrationDb(':memory:') + workerRuntime = new OrcaRuntimeService() + workerRuntime.setOrchestrationDb(workerDb) + vi.spyOn(workerRuntime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? workerPaneKey : null + ) + vi.spyOn(workerRuntime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' ? processIncarnation : null + ) + workerDispatcher = new RpcDispatcher({ + runtime: workerRuntime, + methods: ORCHESTRATION_METHODS + }) + + const transport: OrchestrationEnvironmentTransport = { + resolve: () => ({ + environmentId: 'environment_worker', + name: 'worker', + peerFingerprint: workerPeerFingerprint + }), + call: async (_selector, method, params, _timeoutMs, envelope) => { + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: workerRuntime.getStatus(), + _meta: { runtimeId: workerRuntime.getRuntimeId() } + } + } + const response = (await workerDispatcher.dispatch({ + id: `remote_${method}`, + authToken: homeToken, + method, + params, + orchestrationContractVersion: envelope?.orchestrationContractVersion, + orchestrationRequestId: envelope?.orchestrationRequestId + })) as RuntimeRpcResponse + return response + } + } + homeDb = new OrchestrationDb(':memory:') + homeRuntime = new OrcaRuntimeService(null, undefined, { + orchestrationEnvironmentTransport: transport + }) + homeRuntime.setOrchestrationDb(homeDb) + vi.spyOn(homeRuntime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordinatorPaneKey : null + ) + homeDispatcher = new RpcDispatcher({ + runtime: homeRuntime, + methods: ORCHESTRATION_METHODS + }) + + const run = homeDb.createRun({ + objective: 'Federated control mail', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }) + runId = run.id + const task = homeDb.createTask({ spec: 'Wait for coordinator guidance', runId }) + const started = homeDb.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + federation: { + environmentId: 'environment_worker', + environmentName: 'worker', + peerFingerprint: workerPeerFingerprint, + protocolVersion: 2 + } + }) + dispatchId = started.dispatch.id + homeDb.markWorkerDispatchReady(dispatchId) + + const homeFingerprint = authenticatedCallerFingerprint({ + id: 'home', + authToken: homeToken, + method: 'orchestration.federationImport' + }) + workerDb.createRemoteDispatchAttachment({ + dispatchId, + taskId: task.id, + homePeerFingerprint: homeFingerprint, + protocolVersion: 2, + runtimeEpoch: workerRuntime.getRuntimeId(), + mutationReceipt: { + callerFingerprint: homeFingerprint, + requestId: 'attach-worker', + method: 'orchestration.federationAttachStart', + payloadHash: 'attach-worker-payload' + } + }) + workerDb.prepareRemoteAttachmentAuthority({ + dispatchId, + paneKey: workerPaneKey, + processIncarnation, + worktreeId: 'repo::worker', + terminalHandle: 'term_worker', + setupState: 'not_applicable', + effects: [] + }) + workerDb.markRemoteAttachmentReady(dispatchId) + }) + + afterEach(() => { + homeRuntime.stopOrchestrationFederationRelay() + homeDb.close() + workerDb.close() + }) + + it('routes an exact Dispatch message through the durable relay', async () => { + vi.spyOn(homeRuntime, 'ensureOrchestrationFederationRelay').mockImplementation(() => {}) + const sent = await homeDispatcher.dispatch({ + id: 'send-control', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'send-control-request', + method: 'orchestration.send', + params: { + from: 'term_coord', + to: `dispatch:${dispatchId}`, + subject: 'Continue', + body: 'Run the focused follow-up.', + type: 'status' + } + }) + + expect(sent).toMatchObject({ + ok: true, + result: { relay: { dispatchId, accepted: true } } + }) + expect(homeDb.listPendingFederationRelay(dispatchId, 'to_worker')).toHaveLength(1) + + await homeRuntime.syncOrchestrationFederation() + const checked = await workerDispatcher.dispatch(checkRequest('check-imported')) + + expect(checked).toMatchObject({ + ok: true, + result: { + dispatchId, + count: 1, + messages: [ + { + to_handle: `dispatch:${dispatchId}`, + subject: 'Continue', + body: 'Run the focused follow-up.' + } + ] + } + }) + expect(homeDb.listPendingFederationRelay(dispatchId, 'to_worker')).toHaveLength(0) + }) + + it('wakes a remote worker waiter when control mail imports', async () => { + const waiting = workerDispatcher.dispatch(checkRequest('wait-for-control', true)) + await Promise.resolve() + + const imported = await workerDispatcher.dispatch( + importRequest('import-control', 1, 'relay-control') + ) + + expect(imported).toMatchObject({ + ok: true, + result: { acknowledgedThrough: 1, imported: 1 } + }) + await expect(waiting).resolves.toMatchObject({ + ok: true, + result: { + dispatchId, + count: 1, + messages: [{ id: 'relay-control', subject: 'Continue' }] + } + }) + }) + + it('accepts a repeated import after a lost acknowledgment without duplicating mail', async () => { + const first = await workerDispatcher.dispatch(importRequest('first-import', 1, 'relay-control')) + const repeated = await workerDispatcher.dispatch( + importRequest('repeated-import', 1, 'different-message-id') + ) + + expect(first).toMatchObject({ ok: true, result: { imported: 1 } }) + expect(repeated).toMatchObject({ ok: true, result: { imported: 0 } }) + expect(workerDb.getUnreadMessages(`dispatch:${dispatchId}`)).toHaveLength(1) + }) + + it('does not deliver pending control mail after worker completion', async () => { + vi.spyOn(homeRuntime, 'ensureOrchestrationFederationRelay').mockImplementation(() => {}) + await homeDispatcher.dispatch({ + id: 'send-stale-control', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'send-stale-control-request', + method: 'orchestration.send', + params: { + from: 'term_coord', + to: `dispatch:${dispatchId}`, + subject: 'Stale follow-up', + body: 'This must not arrive after completion.', + type: 'status' + } + }) + const waiting = workerDispatcher.dispatch(checkRequest('wait-before-completion', true, 30)) + await Promise.resolve() + + const taskId = homeDb.getDispatchContextById(dispatchId)!.task_id + workerDb.enqueueFederationRelay({ + dispatchId, + direction: 'to_home', + kind: 'worker_done', + payload: JSON.stringify({ + from: `dispatch:${dispatchId}`, + subject: 'Done', + body: 'Completed before the follow-up arrived.', + type: 'worker_done', + priority: 'normal', + threadId: null, + payload: JSON.stringify({ + taskId, + dispatchId, + outcome: 'succeeded', + filesModified: [] + }) + }), + settleRemoteOutcome: 'succeeded' + }) + + await homeRuntime.syncOrchestrationFederation() + + expect(homeDb.getWorkerDispatch(dispatchId)?.state).toBe('succeeded') + expect(workerDb.getUnreadMessages(`dispatch:${dispatchId}`)).toHaveLength(0) + expect(homeDb.listPendingFederationRelay(dispatchId, 'to_worker')).toHaveLength(1) + await expect( + workerDispatcher.dispatch(importRequest('late-direct-import', 1, 'late-control')) + ).resolves.toMatchObject({ + ok: false, + error: { code: 'dispatch_inactive' } + }) + await expect(waiting).resolves.toMatchObject({ + ok: true, + result: { count: 0, timedOut: true } + }) + }) + + it('wakes only waiters whose filter matches an imported control message', async () => { + const escalationWaiter = workerDispatcher.dispatch( + checkRequest('wait-escalation', true, 1_000, 'escalation') + ) + const statusWaiter = workerDispatcher.dispatch(checkRequest('wait-status', true, 30, 'status')) + await Promise.resolve() + + await workerDispatcher.dispatch( + importRequest('import-escalation', 1, 'relay-escalation', 'escalation') + ) + + await expect(escalationWaiter).resolves.toMatchObject({ + ok: true, + result: { + count: 1, + messages: [{ id: 'relay-escalation', type: 'escalation' }] + } + }) + await expect(statusWaiter).resolves.toMatchObject({ + ok: true, + result: { count: 0, timedOut: true } + }) + }) + + function checkRequest(id: string, wait = false, timeoutMs = 5_000, types?: string): RpcRequest { + return { + id, + authToken: workerToken, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + method: 'orchestration.check', + params: { + terminal: 'term_worker', + wait, + timeoutMs, + types + } + } + } + + function importRequest( + id: string, + sequence: number, + messageId: string, + type = 'status' + ): RpcRequest { + return { + id, + authToken: homeToken, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + method: 'orchestration.federationImport', + params: { + dispatchId, + items: [ + { + dispatch_id: dispatchId, + direction: 'to_worker', + sequence, + message_id: messageId, + kind: 'control_message', + payload: JSON.stringify({ + from: `run:${runId}`, + subject: 'Continue', + body: 'Run the focused follow-up.', + type, + priority: 'normal', + threadId: null, + payload: null + }) + } + ] + } + } + } +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation-control.ts b/src/main/runtime/rpc/methods/orchestration-federation-control.ts new file mode 100644 index 00000000000..215a45c5676 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-control.ts @@ -0,0 +1,207 @@ +import { z } from 'zod' +import { ORCHESTRATION_WORKER_READ_SOURCES } from '../../../../shared/orchestration-worker-output' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { RemoteDispatchAttachmentRow } from '../../orchestration/types' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, requiredString } from '../schemas' +import { readExactWorkerOutput } from './orchestration-worker-output' + +const FederationDispatchParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID') +}) +const FederationReadParams = FederationDispatchParams.extend({ + cursor: OptionalFiniteNumber, + limit: OptionalFiniteNumber +}) +const FederationOutputReadParams = FederationDispatchParams.extend({ + cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), + limit: OptionalFiniteNumber, + source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() +}) + +export const ORCHESTRATION_FEDERATION_CONTROL_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.federationShow', + params: FederationDispatchParams, + handler: async (params, { runtime, authenticatedCallerFingerprint }) => { + const attachment = requireHomeAttachment( + runtime, + params.dispatchId, + authenticatedCallerFingerprint + ) + const observation = await inspectRemoteAttachment(runtime, params.dispatchId) + return { + dispatchId: params.dispatchId, + runtimeEpoch: runtime.getRuntimeId(), + attachment: exposeRemoteAttachment(attachment), + terminal: observation.exact ? observation.terminal : null, + observation: { status: observation.status, exactWorker: observation.exact } + } + } + }), + defineMethod({ + name: 'orchestration.federationRead', + params: FederationReadParams, + handler: async (params, { runtime, authenticatedCallerFingerprint }) => { + requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) + const observation = await inspectRemoteAttachment(runtime, params.dispatchId) + if (!observation.exact || !observation.terminal || observation.status !== 'running') { + throw new OrchestrationError( + 'worker_identity_changed', + `Remote Dispatch ${params.dispatchId} no longer resolves to its exact process.` + ) + } + return { + dispatchId: params.dispatchId, + runtimeEpoch: runtime.getRuntimeId(), + terminal: await runtime.readTerminal(observation.terminal.handle, { + cursor: params.cursor, + limit: params.limit + }) + } + } + }), + defineMethod({ + name: 'orchestration.federationReadOutput', + params: FederationOutputReadParams, + handler: async (params, { runtime, authenticatedCallerFingerprint }) => { + const attachment = requireHomeAttachment( + runtime, + params.dispatchId, + authenticatedCallerFingerprint + ) + const observation = await inspectRemoteAttachment(runtime, params.dispatchId) + if (!observation.exact || !observation.terminal) { + throw new OrchestrationError( + 'worker_identity_changed', + `Remote Dispatch ${params.dispatchId} no longer resolves to its exact process.` + ) + } + const output = await readExactWorkerOutput({ + runtime, + dispatchId: params.dispatchId, + terminalHandle: observation.terminal.handle, + workerState: attachment.state, + terminalStatus: observation.status === 'exited' ? 'exited' : 'running', + attachedAt: attachment.created_at, + source: params.source, + cursor: params.cursor, + limit: params.limit + }) + const afterRead = await inspectRemoteAttachment(runtime, params.dispatchId) + if (!afterRead.exact) { + throw new OrchestrationError( + 'worker_identity_changed', + `Remote Dispatch ${params.dispatchId} changed process while output was read.` + ) + } + return { + dispatchId: params.dispatchId, + runtimeEpoch: runtime.getRuntimeId(), + output + } + } + }), + defineMethod({ + name: 'orchestration.federationStop', + params: FederationDispatchParams, + handler: async (params, { runtime, authenticatedCallerFingerprint }) => { + requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) + const db = runtime.getOrchestrationDb() + const begun = db.beginRemoteAttachmentStop(params.dispatchId) + if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(begun.state)) { + return { + dispatchId: params.dispatchId, + state: begun.state, + alreadySettled: true, + processAction: 'none' + } + } + const observation = await inspectRemoteAttachment(runtime, params.dispatchId) + if (!observation.exact || !observation.terminal) { + const attachment = db.markRemoteAttachmentStopUnknown( + params.dispatchId, + `The recorded worker process is ${observation.status}; no terminal was closed.` + ) + return { + dispatchId: params.dispatchId, + state: attachment.state, + alreadySettled: false, + processAction: 'none', + lastError: attachment.last_error + } + } + try { + const close = await runtime.closeTerminal(observation.terminal.handle) + const attachment = db.settleRemoteAttachmentStop(params.dispatchId) + return { + dispatchId: params.dispatchId, + state: attachment.state, + alreadySettled: false, + processAction: 'closed_agent_terminal', + close + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + const attachment = db.markRemoteAttachmentStopUnknown(params.dispatchId, reason) + return { + dispatchId: params.dispatchId, + state: attachment.state, + alreadySettled: false, + processAction: 'unknown', + lastError: reason + } + } + } + }) +] + +function requireHomeAttachment( + runtime: OrcaRuntimeService, + dispatchId: string, + callerFingerprint: string | undefined +): RemoteDispatchAttachmentRow { + const attachment = runtime.getOrchestrationDb().getRemoteDispatchAttachment(dispatchId) + if (!attachment || attachment.home_peer_fingerprint !== callerFingerprint) { + throw new OrchestrationError( + 'dispatch_not_found', + `Remote Dispatch ${dispatchId} was not found for this Run home.` + ) + } + return attachment +} + +async function inspectRemoteAttachment(runtime: OrcaRuntimeService, dispatchId: string) { + const db = runtime.getOrchestrationDb() + const attachment = db.getRemoteDispatchAttachment(dispatchId) + if (!attachment?.terminal_handle) { + return { terminal: null, exact: false, status: 'unattached' as const } + } + const terminal = await runtime.showTerminal(attachment.terminal_handle).catch(() => null) + if (!terminal) { + return { terminal: null, exact: false, status: 'missing' as const } + } + const exact = db.isRemoteAttachmentProcessCurrent({ + dispatchId, + paneKey: runtime.getTerminalPaneKey(attachment.terminal_handle), + processIncarnation: runtime.getTerminalProcessIncarnation(attachment.terminal_handle) + }) + return { + terminal, + exact, + status: exact + ? terminal.connected === false + ? ('exited' as const) + : ('running' as const) + : ('identity_changed' as const) + } +} + +function exposeRemoteAttachment(attachment: RemoteDispatchAttachmentRow) { + return { + ...attachment, + effects: JSON.parse(attachment.effects) as unknown[], + residualResources: JSON.parse(attachment.residual_resources) as unknown[] + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation-effects.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-effects.test.ts new file mode 100644 index 00000000000..b4c1cd37589 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-effects.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { + appendFederationSetupEffect, + appendFederationTerminalEffects, + type FederationEffect +} from './orchestration-federation-effects' + +describe('orchestration federation effects', () => { + it('uses exact terminal handles instead of display titles for setup identity', () => { + const effects: FederationEffect[] = [] + + appendFederationTerminalEffects( + effects, + [ + { handle: 'term_agent', title: 'Codex' }, + { handle: 'term_configured', title: 'Setup' }, + { handle: 'term_setup', title: 'PowerShell' } + ], + 'term_agent', + 'term_setup' + ) + appendFederationSetupEffect(effects, { + requested: 'run', + effective: 'run', + source: 'orchestration_default', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'running' + }) + + expect(effects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: 'term_configured', role: 'configured_tab' }), + expect.objectContaining({ id: 'term_setup', role: 'setup' }), + expect.objectContaining({ kind: 'setup', terminalId: 'term_setup' }) + ]) + ) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation-effects.ts b/src/main/runtime/rpc/methods/orchestration-federation-effects.ts new file mode 100644 index 00000000000..da0bcf92894 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-effects.ts @@ -0,0 +1,79 @@ +export type FederationEffect = { + kind: 'worktree' | 'terminal' | 'setup' | 'dispatch_input' + action?: string + role?: string + id?: string + state?: string + tabId?: string + leafId?: string + requested?: string + effective?: string + source?: string + hookFound?: boolean + startupPolicy?: string + terminalId?: string +} + +export function appendFederationTerminalEffects( + effects: FederationEffect[], + terminals: { handle: string; title: string | null; tabId?: string; leafId?: string }[], + agentHandle: string, + setupHandle?: string +): void { + for (const terminal of terminals) { + effects.push({ + kind: 'terminal', + role: + terminal.handle === agentHandle + ? 'agent' + : terminal.handle === setupHandle + ? 'setup' + : 'configured_tab', + action: terminal.handle === agentHandle ? 'reused_agent_terminal' : 'created', + id: terminal.handle, + tabId: terminal.tabId, + leafId: terminal.leafId + }) + } +} + +export function appendFederationSetupEffect( + effects: FederationEffect[], + setup: { + requested: string + effective: string + source: string + hookFound: boolean + startupPolicy: string + state: string + } +): void { + const setupTerminal = effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'setup' + ) + effects.push({ + kind: 'setup', + action: setup.requested, + ...setup, + terminalId: setupTerminal?.id + }) +} + +export function isFederationResidualEffect(effect: FederationEffect): boolean { + return Boolean(effect.action?.startsWith('created') || effect.action === 'reused_agent_terminal') +} + +export function isFederationEffectUnknown(error: unknown, stage: string): boolean { + const code = + error && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'string' + ? (error as { code: string }).code + : '' + if (code === 'operation_unknown') { + return true + } + if (!['worktree_create', 'terminal_create', 'dispatch_input'].includes(stage)) { + return false + } + const message = error instanceof Error ? error.message : String(error) + return /connection|disconnect|timed?\s*out|runtime changed|outcome unknown/i.test(message) +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation-folder-placement.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-folder-placement.test.ts new file mode 100644 index 00000000000..8806c92fe47 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-folder-placement.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { ORCHESTRATION_METHODS } from './orchestration' + +describe('orchestration federated folder placement', () => { + let db: OrchestrationDb | undefined + + afterEach(() => db?.close()) + + it('rejects a new folder workspace before accepting the remote attachment', async () => { + db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ + id: 'folder-repo', + kind: 'folder' + } as never) + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.federationAttachStart' + ) + if (!method) { + throw new Error('federationAttachStart method is not registered') + } + + await expect( + method.handler( + method.params!.parse({ + dispatchId: 'ctx_folder', + taskId: 'task_folder', + taskSpec: 'work in folder', + protocolVersion: 1, + worktree: 'new-top-level', + repo: 'folder-repo', + name: 'folder-worker', + agent: 'codex' + }), + { + runtime, + orchestrationMutation: { + callerFingerprint: 'home_peer', + requestId: 'request_folder', + method: 'orchestration.federationAttachStart', + payloadHash: 'folder_payload' + } + } + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: + 'Folder projects cannot create orchestration worktrees; use an exact existing folder workspace.' + }) + expect(db.getRemoteDispatchAttachment('ctx_folder')).toBeUndefined() + expect(db.getMutationReceipt('home_peer', 'request_folder')).toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation-methods.ts b/src/main/runtime/rpc/methods/orchestration-federation-methods.ts new file mode 100644 index 00000000000..171125602a0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-methods.ts @@ -0,0 +1,10 @@ +import type { RpcMethod } from '../core' +import { ORCHESTRATION_FEDERATION_CONTROL_METHODS } from './orchestration-federation-control' +import { ORCHESTRATION_FEDERATION_RELAY_METHODS } from './orchestration-federation-relay' +import { ORCHESTRATION_FEDERATION_ATTACH_METHODS } from './orchestration-federation' + +export const ORCHESTRATION_FEDERATION_METHODS: RpcMethod[] = [ + ...ORCHESTRATION_FEDERATION_ATTACH_METHODS, + ...ORCHESTRATION_FEDERATION_RELAY_METHODS, + ...ORCHESTRATION_FEDERATION_CONTROL_METHODS +] diff --git a/src/main/runtime/rpc/methods/orchestration-federation-output.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-output.test.ts new file mode 100644 index 00000000000..9617c05c125 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-output.test.ts @@ -0,0 +1,312 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' +import type { RpcRequest } from '../core' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' + +describe('orchestration federated worker output', () => { + const databases: OrchestrationDb[] = [] + let homeDb: OrchestrationDb + let workerDb: OrchestrationDb + let homeRuntime: OrcaRuntimeService + let workerRuntime: OrcaRuntimeService + let homeDispatcher: RpcDispatcher + let workerDispatcher: RpcDispatcher + let workerSupportsStructuredRead: boolean + + beforeEach(() => { + homeDb = new OrchestrationDb(':memory:') + workerDb = new OrchestrationDb(':memory:') + databases.push(homeDb, workerDb) + workerRuntime = new OrcaRuntimeService() + workerRuntime.setOrchestrationDb(workerDb) + workerDispatcher = new RpcDispatcher({ + runtime: workerRuntime, + methods: ORCHESTRATION_METHODS + }) + workerSupportsStructuredRead = true + const transport: OrchestrationEnvironmentTransport = { + resolve: () => ({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer_fingerprint' + }), + call: async (_selector, method, params, _timeoutMs, envelope) => { + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: workerRuntime.getStatus(), + _meta: { runtimeId: workerRuntime.getRuntimeId() } + } + } + if (method === 'orchestration.federationReadOutput' && !workerSupportsStructuredRead) { + return { + id: `remote_${method}`, + ok: false, + error: { code: 'method_not_found', message: `Unknown method: ${method}` } + } + } + return (await workerDispatcher.dispatch({ + id: `remote_${method}`, + authToken: 'run-home-device-token', + method, + params, + orchestrationContractVersion: envelope?.orchestrationContractVersion, + orchestrationRequestId: envelope?.orchestrationRequestId, + orchestrationCapability: envelope?.orchestrationCapability + })) as RuntimeRpcResponse + } + } + homeRuntime = new OrcaRuntimeService(null, undefined, { + orchestrationEnvironmentTransport: transport + }) + homeRuntime.setOrchestrationDb(homeDb) + homeDispatcher = new RpcDispatcher({ + runtime: homeRuntime, + methods: ORCHESTRATION_METHODS + }) + vi.spyOn(homeRuntime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' : null + ) + configureWorkerRuntime(workerRuntime) + }) + + afterEach(() => { + homeRuntime.stopOrchestrationFederationRelay() + for (const db of databases.splice(0)) { + db.close() + } + }) + + function createHomeTask() { + const run = homeDb.createRun({ + objective: 'Mac to Windows output', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + return homeDb.createTask({ spec: 'Read Windows worker output', runId: run.id }) + } + + function startRequest(taskId: string): RpcRequest { + return { + id: 'rpc_worker_start', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_windows_worker', + method: 'orchestration.workerStart', + params: { + task: taskId, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'windows-output', + agent: 'codex' + } + } + } + + function configureWorkerRuntime(runtime: OrcaRuntimeService): void { + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ + id: 'windows-repo', + kind: 'git' + } as never) + vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({ + worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_windows_worker' }, + setupReceipt: { + requested: 'run', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_configured' + } + } as never) + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [{ handle: 'term_windows_worker', title: 'Codex' }], + totalCount: 1, + truncated: false + } as never) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue( + 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('windows_runtime:pty:1') + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_windows_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + worktreeId: 'repo::windows-worktree', + status: 'running' + } as never) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + status: 'running', + tail: ['remote output'], + truncated: false, + nextCursor: '1' + }) + } + + async function startRemoteWorker(): Promise { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + return homeDb.getDispatchContext(task.id)!.id + } + + it('routes show and read by Dispatch without repeating the worker server', async () => { + const dispatchId = await startRemoteWorker() + + const shown = await homeDispatcher.dispatch({ + id: 'rpc_remote_show', + authToken: 'coordinator-token', + method: 'orchestration.workerShow', + params: { dispatch: dispatchId } + }) + const read = await homeDispatcher.dispatch({ + id: 'rpc_remote_read', + authToken: 'coordinator-token', + method: 'orchestration.workerRead', + params: { dispatch: dispatchId, limit: 20 } + }) + + expect(shown).toMatchObject({ + ok: true, + result: { + server: { environmentId: 'environment_windows', name: 'windows' }, + observation: { status: 'running', exactWorker: true }, + terminal: { handle: 'term_windows_worker' } + } + }) + expect(read).toMatchObject({ + ok: true, + result: { + source: 'terminal', + fallbackReason: 'session_not_reported', + server: { environmentId: 'environment_windows', name: 'windows' }, + terminal: { tail: ['remote output'] } + } + }) + }) + + it('keeps an opaque terminal cursor across mixed server versions', async () => { + const dispatchId = await startRemoteWorker() + workerSupportsStructuredRead = false + + const automatic = await homeDispatcher.dispatch({ + id: 'rpc_remote_legacy_read', + authToken: 'coordinator-token', + method: 'orchestration.workerRead', + params: { dispatch: dispatchId } + }) + const cursor = (automatic as { result: { cursor: string } }).result.cursor + const continued = await homeDispatcher.dispatch({ + id: 'rpc_remote_legacy_continue', + authToken: 'coordinator-token', + method: 'orchestration.workerRead', + params: { dispatch: dispatchId, cursor } + }) + const required = await homeDispatcher.dispatch({ + id: 'rpc_remote_legacy_transcript', + authToken: 'coordinator-token', + method: 'orchestration.workerRead', + params: { dispatch: dispatchId, source: 'transcript' } + }) + + expect(automatic).toMatchObject({ + ok: true, + result: { + source: 'terminal', + fallbackReason: 'remote_capability_unavailable', + terminal: { tail: ['remote output'] } + } + }) + expect(cursor).toMatch(/^owr1_/) + expect(continued).toMatchObject({ + ok: true, + result: { + source: 'terminal', + fallbackReason: 'remote_capability_unavailable' + } + }) + expect((continued as { result: { cursor: string } }).result.cursor).toMatch(/^owr1_/) + expect(required).toMatchObject({ + ok: false, + error: { + code: 'transcript_required', + data: { reason: 'remote_capability_unavailable' } + } + }) + }) + + it('reads the exact transcript on the worker server without leaking its path home', async () => { + const dispatchId = await startRemoteWorker() + const directory = await mkdtemp(join(tmpdir(), 'orca-federated-worker-output-')) + const transcriptPath = join(directory, 'windows-session.jsonl') + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'event_msg', + payload: { id: 'remote-message', type: 'agent_message', message: 'Windows result' } + })}\n` + ) + vi.spyOn(workerRuntime, 'getExactWorkerProviderSession').mockReturnValue({ + paneKey: 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + processIncarnation: 'windows_runtime:pty:1', + agent: 'codex', + providerSession: { + key: 'session_id', + id: 'windows-session', + transcriptPath + }, + observedAt: Date.now() + }) + + try { + const response = await homeDispatcher.dispatch({ + id: 'rpc_remote_transcript_read', + authToken: 'coordinator-token', + method: 'orchestration.workerRead', + params: { dispatch: dispatchId } + }) + + expect(response).toMatchObject({ + ok: true, + result: { + source: 'transcript', + provider: 'codex', + server: { environmentId: 'environment_windows' }, + transcript: { + messages: [ + { + id: 'remote-message', + blocks: [{ type: 'text', text: 'Windows result' }] + } + ] + } + } + }) + expect(JSON.stringify(response)).not.toContain(transcriptPath) + } finally { + await rm(directory, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation-relay.ts b/src/main/runtime/rpc/methods/orchestration-federation-relay.ts new file mode 100644 index 00000000000..fbc3e97fb3b --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-relay.ts @@ -0,0 +1,179 @@ +import { z } from 'zod' +import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' +import { importFederatedControlMessage } from '../../orchestration/federation-control-message' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, requiredString } from '../schemas' + +const FederationPullParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + afterSequence: OptionalFiniteNumber, + limit: OptionalFiniteNumber +}) + +const FederationAckParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + throughSequence: z.number().int().nonnegative() +}) + +const FederationImportParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + items: z.array( + z.object({ + dispatch_id: requiredString('Missing item Dispatch ID'), + direction: z.literal('to_worker'), + sequence: z.number().int().positive(), + message_id: requiredString('Missing relay message ID'), + kind: requiredString('Missing relay kind'), + payload: requiredString('Missing relay payload') + }) + ) +}) + +export const ORCHESTRATION_FEDERATION_RELAY_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.federationPull', + params: FederationPullParams, + handler: (params, { runtime, authenticatedCallerFingerprint }) => { + requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) + return { + dispatchId: params.dispatchId, + runtimeEpoch: runtime.getRuntimeId(), + items: runtime.getOrchestrationDb().listFederationRelay({ + dispatchId: params.dispatchId, + direction: 'to_home', + afterSequence: params.afterSequence ?? 0, + limit: params.limit + }) + } + } + }), + defineMethod({ + name: 'orchestration.federationAck', + params: FederationAckParams, + handler: (params, { runtime, authenticatedCallerFingerprint }) => { + requireHomeAttachment(runtime, params.dispatchId, authenticatedCallerFingerprint) + runtime.getOrchestrationDb().acknowledgeFederationRelay({ + dispatchId: params.dispatchId, + direction: 'to_home', + throughSequence: params.throughSequence + }) + return { dispatchId: params.dispatchId, acknowledgedThrough: params.throughSequence } + } + }), + defineMethod({ + name: 'orchestration.federationImport', + params: FederationImportParams, + handler: (params, { runtime, authenticatedCallerFingerprint }) => { + const db = runtime.getOrchestrationDb() + const attachment = requireHomeAttachment( + runtime, + params.dispatchId, + authenticatedCallerFingerprint + ) + let cursor = attachment.to_worker_imported_sequence + let imported = 0 + for (const item of params.items) { + if (item.dispatch_id !== params.dispatchId || item.sequence > cursor + 1) { + throw new OrchestrationError( + 'operation_unknown', + `Home relay for ${params.dispatchId} is not contiguous after sequence ${cursor}.` + ) + } + if (item.sequence <= cursor) { + continue + } + const currentAttachment = requireHomeAttachment( + runtime, + params.dispatchId, + authenticatedCallerFingerprint + ) + if (currentAttachment.state !== 'ready') { + throw new OrchestrationError( + 'dispatch_inactive', + `Remote Dispatch ${params.dispatchId} is not active.` + ) + } + if (item.kind === 'reply') { + const reply = parseFederatedReply(item.payload) + db.answerRemoteQuestion({ + messageId: reply.questionId, + dispatchId: params.dispatchId, + answerMessageId: reply.answerMessageId, + body: reply.body + }) + runtime.notifyMessageArrived(`dispatch:${params.dispatchId}`, 'status') + } else if (item.kind === 'control_message') { + if ( + currentAttachment.protocol_version < + ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION + ) { + throw new OrchestrationError( + 'capability_unsupported', + `Remote Dispatch ${params.dispatchId} does not support coordinator control mail.` + ) + } + const controlMessage = importFederatedControlMessage(db, { + dispatchId: params.dispatchId, + messageId: item.message_id, + payload: item.payload + }) + imported += controlMessage.imported ? 1 : 0 + if (controlMessage.imported) { + runtime.notifyMessageArrived(`dispatch:${params.dispatchId}`, controlMessage.type) + } + } else { + throw new OrchestrationError( + 'invalid_argument', + `Federated worker relay kind ${item.kind} is not supported.` + ) + } + cursor = item.sequence + db.setRemoteWorkerImportSequence(params.dispatchId, cursor) + } + return { dispatchId: params.dispatchId, acknowledgedThrough: cursor, imported } + } + }) +] + +function requireHomeAttachment( + runtime: Parameters[1]['runtime'], + dispatchId: string, + callerFingerprint: string | undefined +) { + const attachment = runtime.getOrchestrationDb().getRemoteDispatchAttachment(dispatchId) + if (!attachment || attachment.home_peer_fingerprint !== callerFingerprint) { + throw new OrchestrationError( + 'dispatch_not_found', + `Remote Dispatch ${dispatchId} was not found for this Run home.` + ) + } + return attachment +} + +function parseFederatedReply(payload: string): { + questionId: string + answerMessageId: string + body: string +} { + let parsed: unknown + try { + parsed = JSON.parse(payload) + } catch { + throw new OrchestrationError('invalid_argument', 'Federated reply payload is invalid JSON.') + } + const reply = parsed as Record | null + if ( + !reply || + typeof reply.questionId !== 'string' || + typeof reply.answerMessageId !== 'string' || + typeof reply.body !== 'string' + ) { + throw new OrchestrationError('invalid_argument', 'Federated reply payload is incomplete.') + } + return { + questionId: reply.questionId, + answerMessageId: reply.answerMessageId, + body: reply.body + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation-setup.test.ts b/src/main/runtime/rpc/methods/orchestration-federation-setup.test.ts new file mode 100644 index 00000000000..70c08e2ce95 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-setup.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { ORCHESTRATION_METHODS } from './orchestration' +import { monitorFederatedSetup } from './orchestration-federation-setup' + +describe('orchestration federated setup evidence', () => { + const databases: OrchestrationDb[] = [] + const runtimes: OrcaRuntimeService[] = [] + + afterEach(() => { + for (const runtime of runtimes.splice(0)) { + runtime.stopOrchestrationFederationRelay() + } + for (const db of databases.splice(0)) { + db.close() + } + }) + + function createRuntime(): { db: OrchestrationDb; runtime: OrcaRuntimeService } { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + databases.push(db) + runtimes.push(runtime) + return { db, runtime } + } + + it('records remote setup evidence once without changing attachment lifecycle', async () => { + const { db, runtime } = createRuntime() + const dispatchId = 'ctx_remote_setup' + const effects = [ + { + kind: 'terminal' as const, + role: 'setup', + action: 'created', + id: 'term_remote_setup' + }, + { + kind: 'setup' as const, + action: 'run', + state: 'running' + }, + { + kind: 'dispatch_input' as const, + role: 'agent', + id: 'term_remote_worker', + state: 'accepted' + } + ] + db.createRemoteDispatchAttachment({ + dispatchId, + taskId: 'task_remote_setup', + homePeerFingerprint: 'home_peer', + protocolVersion: 1, + runtimeEpoch: runtime.getRuntimeId(), + mutationReceipt: { + callerFingerprint: 'home_peer', + requestId: 'request_remote_setup', + method: 'orchestration.federationAttachStart', + payloadHash: 'remote_setup_payload' + } + }) + db.prepareRemoteAttachmentAuthority({ + dispatchId, + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'worker_epoch:pty:1', + worktreeId: 'repo::remote-worktree', + terminalHandle: 'term_remote_worker', + setupState: 'running', + effects + }) + db.markRemoteAttachmentReady(dispatchId) + vi.spyOn(runtime, 'waitForSetupTerminalCompletion').mockResolvedValue({ exitCode: 1 }) + const monitorArgs = { + runtime, + db, + dispatchId, + worktreeId: 'repo::remote-worktree', + terminalHandle: 'term_remote_worker', + setup: { + requested: 'run' as const, + effective: 'run' as const, + source: 'orchestration_default', + hookFound: true, + startupPolicy: 'start-immediately' as const, + state: 'running' as const + }, + effects + } + + monitorFederatedSetup(monitorArgs) + monitorFederatedSetup(monitorArgs) + + await vi.waitFor(() => + expect(db.getRemoteDispatchAttachment(dispatchId)).toMatchObject({ + state: 'ready', + stage: 'input_accepted', + setup_state: 'failed' + }) + ) + expect( + db.listFederationRelay({ dispatchId, direction: 'to_home', afterSequence: 0 }) + ).toHaveLength(1) + expect(JSON.parse(db.getRemoteDispatchAttachment(dispatchId)?.effects ?? '[]')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + ) + }) + + it('refreshes home setup evidence without changing a ready Dispatch lifecycle', async () => { + const { db, runtime } = createRuntime() + const run = db.createRun({ + objective: 'Observe remote setup', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote setup', runId: run.id }) + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + runtimeEpoch: runtime.getRuntimeId(), + federation: { + environmentId: 'environment_windows', + environmentName: 'windows', + peerFingerprint: 'windows_peer', + protocolVersion: 1 + } + }) + db.recordWorkerStage({ + dispatchId: started.dispatch.id, + stage: 'terminal_readying', + setupState: 'running', + effects: [ + { kind: 'setup', action: 'run', state: 'running' }, + { + kind: 'dispatch_input', + role: 'agent', + id: 'term_remote_worker', + state: 'accepted' + } + ] + }) + db.markWorkerDispatchReady(started.dispatch.id) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer' + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockResolvedValue({ + runtimeEpoch: 'windows_epoch', + attachment: { + state: 'ready', + stage: 'input_accepted', + last_error: null, + worktree_id: 'repo::remote-worktree', + terminal_handle: 'term_remote_worker', + setup_state: 'failed', + effects: [ + { kind: 'setup', action: 'run', state: 'failed' }, + { + kind: 'dispatch_input', + role: 'agent', + id: 'term_remote_worker', + state: 'accepted' + } + ], + residualResources: [] + }, + terminal: { handle: 'term_remote_worker', connected: true }, + observation: { status: 'running', exactWorker: true } + }) + const workerShow = ORCHESTRATION_METHODS.find( + (method) => method.name === 'orchestration.workerShow' + ) + if (!workerShow) { + throw new Error('workerShow method is not registered') + } + + await expect( + workerShow.handler(workerShow.params!.parse({ dispatch: started.dispatch.id }), { runtime }) + ).resolves.toMatchObject({ + worker: { + state: 'ready', + stage: 'input_accepted', + setup_state: 'failed', + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'setup', state: 'failed' }), + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + } + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation-setup.ts b/src/main/runtime/rpc/methods/orchestration-federation-setup.ts new file mode 100644 index 00000000000..3f35e2c46ca --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-setup.ts @@ -0,0 +1,99 @@ +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' +import { applyWaitForSetupOutcome, type WorkerSetupReceipt } from './orchestration-worker-topology' +import { + isFederationResidualEffect, + type FederationEffect +} from './orchestration-federation-effects' + +type FederationSetupStageArgs = { + db: OrchestrationDb + dispatchId: string + worktreeId: string + terminalHandle: string + setup: WorkerSetupReceipt + effects: FederationEffect[] +} + +function recordStage(args: FederationSetupStageArgs, stage: string): void { + args.db.recordRemoteAttachmentStage({ + dispatchId: args.dispatchId, + stage, + worktreeId: args.worktreeId, + terminalHandle: args.terminalHandle, + setupState: args.setup.state, + effects: args.effects, + residualResources: args.effects.filter(isFederationResidualEffect) + }) +} + +export function persistFederatedReadinessStage(args: FederationSetupStageArgs): void { + recordStage(args, 'terminal_readying') +} + +export function persistFederatedSetupSpawnFailure(args: FederationSetupStageArgs): boolean { + if (args.setup.startupPolicy !== 'wait-for-setup' || args.setup.state !== 'spawn_failed') { + return false + } + recordStage(args, 'setup_start') + return true +} + +export function persistFederatedSetupWaitOutcome( + args: FederationSetupStageArgs & { wait: { satisfied: boolean; status: string } } +): void { + applyWaitForSetupOutcome(args.setup, args.effects, args.wait) + if (args.setup.startupPolicy === 'wait-for-setup') { + recordStage(args, args.setup.state === 'failed' ? 'setup_failed' : 'setup_settled') + } +} + +export function monitorFederatedSetup( + args: FederationSetupStageArgs & { runtime: OrcaRuntimeService } +): void { + const setupTerminal = args.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'setup' && effect.id + ) + if ( + !setupTerminal?.id || + args.setup.startupPolicy !== 'start-immediately' || + args.setup.state !== 'running' + ) { + return + } + void args.runtime + .waitForSetupTerminalCompletion(setupTerminal.id) + .then((completion) => { + const setupState = completion.exitCode === 0 ? 'succeeded' : 'failed' + const effects = args.effects.map((effect) => + effect.kind === 'setup' ? { ...effect, state: setupState } : effect + ) + const evidence = args.db.updateRemoteAttachmentSetupEvidence({ + dispatchId: args.dispatchId, + setupState, + effects + }) + if (!evidence.changed) { + return + } + args.db.enqueueFederationRelay({ + dispatchId: args.dispatchId, + direction: 'to_home', + kind: 'status', + payload: JSON.stringify({ + from: `dispatch:${args.dispatchId}`, + subject: `Setup ${setupState} for worker ${args.dispatchId}`, + body: '', + type: 'status', + priority: setupState === 'failed' ? 'high' : 'normal', + threadId: null, + payload: JSON.stringify({ + dispatchId: args.dispatchId, + setupState, + terminalHandle: setupTerminal.id + }) + }) + }) + }) + .catch(() => undefined) +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts new file mode 100644 index 00000000000..b7e2da76531 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-receipt.ts @@ -0,0 +1,32 @@ +import type { OrchestrationDb } from '../../orchestration/db' +import { isFederationEffectUnknown } from './orchestration-federation-effects' +import type { WorkerSetupReceipt } from './orchestration-worker-topology' + +export function failFederatedAttachmentWithReceipt(args: { + db: OrchestrationDb + dispatchId: string + runtimeEpoch: string + failedStage: string + error: unknown + setup: WorkerSetupReceipt +}): unknown { + const reason = args.error instanceof Error ? args.error.message : String(args.error) + const unknown = isFederationEffectUnknown(args.error, args.failedStage) + const attachment = args.db.failRemoteAttachment( + args.dispatchId, + args.failedStage, + reason, + unknown + ) + return { + dispatchId: args.dispatchId, + state: attachment.state === 'start_unknown' ? 'outcome_unknown' : attachment.state, + stage: attachment.stage, + runtimeEpoch: args.runtimeEpoch, + failedStage: args.failedStage, + lastError: reason, + setup: args.setup, + effects: JSON.parse(attachment.effects) as unknown[], + residualResources: JSON.parse(attachment.residual_resources) as unknown[] + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts new file mode 100644 index 00000000000..a657ff7d09c --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation-start-schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +export const FederationAttachStartParams = z.object({ + dispatchId: requiredString('Missing Dispatch ID'), + taskId: requiredString('Missing Task ID'), + taskSpec: requiredString('Missing Task spec'), + protocolVersion: z.union([z.literal(1), z.literal(2)]), + worktree: requiredString('Missing remote worktree selector'), + name: OptionalString, + repo: OptionalString, + baseBranch: OptionalString, + displayName: OptionalString, + comment: OptionalString, + setup: z.enum(['run', 'skip', 'inherit']).optional(), + setupSource: z.enum(['explicit_request', 'orchestration_default']).optional(), + terminal: OptionalString, + agent: OptionalString, + timeoutMs: OptionalFiniteNumber, + devMode: z.boolean().optional() +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation.test.ts b/src/main/runtime/rpc/methods/orchestration-federation.test.ts new file mode 100644 index 00000000000..eb76d094368 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation.test.ts @@ -0,0 +1,862 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { RuntimeRpcResponse } from '../../../../shared/runtime-rpc-envelope' +import { + ORCHESTRATION_CONTRACT_VERSION, + ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import type { OrchestrationEnvironmentTransport } from '../../orchestration/environment-transport' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { ORCHESTRATION_METHODS } from './orchestration' + +describe('orchestration federation', () => { + const databases: OrchestrationDb[] = [] + let homeDb: OrchestrationDb + let workerDb: OrchestrationDb + let homeRuntime: OrcaRuntimeService + let workerRuntime: OrcaRuntimeService + let homeDispatcher: RpcDispatcher + let workerDispatcher: RpcDispatcher + let workerCapabilities: string[] + let workerPeerFingerprint: string + let loseNextAckResponse: boolean + + beforeEach(() => { + homeDb = new OrchestrationDb(':memory:') + workerDb = new OrchestrationDb(':memory:') + databases.push(homeDb, workerDb) + workerRuntime = new OrcaRuntimeService() + workerRuntime.setOrchestrationDb(workerDb) + workerDispatcher = new RpcDispatcher({ + runtime: workerRuntime, + methods: ORCHESTRATION_METHODS + }) + workerCapabilities = [...(workerRuntime.getStatus().capabilities ?? [])] + workerPeerFingerprint = 'windows_peer_fingerprint' + loseNextAckResponse = false + const transport: OrchestrationEnvironmentTransport = { + resolve: () => ({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: workerPeerFingerprint + }), + call: async (_selector, method, params, _timeoutMs, envelope) => { + if (method === 'status.get') { + return { + id: 'status', + ok: true, + result: { ...workerRuntime.getStatus(), capabilities: workerCapabilities }, + _meta: { runtimeId: workerRuntime.getRuntimeId() } + } + } + const response = (await workerDispatcher.dispatch({ + id: `remote_${method}`, + authToken: 'run-home-device-token', + method, + params, + orchestrationContractVersion: envelope?.orchestrationContractVersion, + orchestrationRequestId: envelope?.orchestrationRequestId, + orchestrationCapability: envelope?.orchestrationCapability + })) as RuntimeRpcResponse + if (method === 'orchestration.federationAck' && loseNextAckResponse) { + loseNextAckResponse = false + throw new Error('connection lost after acknowledgment') + } + return response + } + } + homeRuntime = new OrcaRuntimeService(null, undefined, { + orchestrationEnvironmentTransport: transport + }) + homeRuntime.setOrchestrationDb(homeDb) + homeDispatcher = new RpcDispatcher({ + runtime: homeRuntime, + methods: ORCHESTRATION_METHODS + }) + vi.spyOn(homeRuntime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' : null + ) + configureWorkerRuntime(workerRuntime) + }) + + afterEach(() => { + homeRuntime.stopOrchestrationFederationRelay() + for (const db of databases.splice(0)) { + db.close() + } + }) + + function createHomeTask() { + const run = homeDb.createRun({ + objective: 'Mac to Windows', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + return homeDb.createTask({ spec: 'Audit Windows behavior', runId: run.id }) + } + + function startRequest(taskId: string, overrides: Record = {}): RpcRequest { + return { + id: 'rpc_worker_start', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_windows_worker', + method: 'orchestration.workerStart', + params: { + task: taskId, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'windows-audit', + agent: 'codex', + ...overrides + } + } + } + + function configureWorkerRuntime(runtime: OrcaRuntimeService): void { + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ + id: 'windows-repo', + kind: 'git' + } as never) + vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({ + worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_windows_worker' }, + setupReceipt: { + requested: 'run', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'running' + } + } as never) + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + { handle: 'term_windows_worker', title: 'Codex' }, + { handle: 'term_windows_setup', title: 'Setup' } + ], + totalCount: 2, + truncated: false + } as never) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue( + 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('windows_runtime:pty:1') + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_windows_worker', + accepted: true, + bytesWritten: 1 + }) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + worktreeId: 'repo::windows-worktree', + status: 'running' + } as never) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + status: 'running', + entries: [{ cursor: 1, text: 'remote output' }], + nextCursor: '1', + limited: false + } as never) + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_windows_worker', + closed: true + } as never) + } + + function restartWorkerRuntime(): void { + workerRuntime = new OrcaRuntimeService() + workerRuntime.setOrchestrationDb(workerDb) + configureWorkerRuntime(workerRuntime) + workerDispatcher = new RpcDispatcher({ + runtime: workerRuntime, + methods: ORCHESTRATION_METHODS + }) + workerCapabilities = [...(workerRuntime.getStatus().capabilities ?? [])] + } + + it('starts a remote worker while keeping authoritative Task state at home', async () => { + const task = createHomeTask() + + const response = await homeDispatcher.dispatch(startRequest(task.id)) + + expect(response).toMatchObject({ + ok: true, + result: { + taskId: task.id, + state: 'ready', + server: { environmentId: 'environment_windows', name: 'windows' }, + setup: { source: 'orchestration_default' }, + mutation: { requestId: 'request_windows_worker' } + } + }) + const dispatch = homeDb.getDispatchContext(task.id)! + expect(homeDb.getTask(task.id)?.status).toBe('dispatched') + expect(homeDb.getFederatedDispatch(dispatch.id)).toMatchObject({ + environment_id: 'environment_windows', + environment_name: 'windows', + peer_fingerprint: 'windows_peer_fingerprint', + remote_worktree_id: 'repo::windows-worktree', + remote_terminal_handle: 'term_windows_worker' + }) + expect(workerDb.getRemoteDispatchAttachment(dispatch.id)).toMatchObject({ + task_id: task.id, + protocol_version: 2, + state: 'ready', + worktree_id: 'repo::windows-worktree', + terminal_handle: 'term_windows_worker' + }) + expect(JSON.parse(workerDb.getRemoteDispatchAttachment(dispatch.id)?.effects ?? '[]')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + ) + expect(workerDb.listTasks()).toHaveLength(0) + expect(workerRuntime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_windows_worker', + expect.stringContaining(`Your task ID is: ${task.id}`) + ) + }) + + it('preserves wait-for-setup gating on the connected worker server', async () => { + vi.mocked(workerRuntime.createManagedWorktree).mockResolvedValueOnce({ + worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_windows_worker' }, + setupReceipt: { + requested: 'run', + hookFound: true, + startupPolicy: 'wait-for-setup', + state: 'running' + } + } as never) + const task = createHomeTask() + + const response = await homeDispatcher.dispatch(startRequest(task.id, { setup: 'run' })) + + expect(response).toMatchObject({ + ok: true, + result: { + state: 'ready', + setup: { startupPolicy: 'wait-for-setup', state: 'succeeded' }, + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'setup', state: 'succeeded' }), + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + } + }) + expect(response).toHaveProperty('result.setup.source', 'explicit_request') + expect(workerRuntime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + }) + + it('fails before remote task input when wait-for-setup fails', async () => { + vi.mocked(workerRuntime.createManagedWorktree).mockResolvedValueOnce({ + worktree: { id: 'repo::windows-worktree', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_windows_worker' }, + setupReceipt: { + requested: 'run', + hookFound: true, + startupPolicy: 'wait-for-setup', + state: 'running' + } + } as never) + vi.mocked(workerRuntime.waitForTerminal).mockResolvedValueOnce({ + handle: 'term_windows_worker', + condition: 'tui-idle', + satisfied: false, + status: 'exited', + exitCode: 1 + }) + const task = createHomeTask() + + const response = await homeDispatcher.dispatch(startRequest(task.id)) + + expect(response).toMatchObject({ + ok: true, + result: { + state: 'failed', + failedStage: 'setup_wait', + setup: { state: 'failed' }, + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'setup', state: 'failed' }) + ]) + } + }) + expect(homeDb.getTask(task.id)?.status).toBe('failed') + expect(workerRuntime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('rejects control mail before queueing when the worker lacks that capability', async () => { + workerCapabilities = workerCapabilities.filter( + (capability) => capability !== ORCHESTRATION_FEDERATION_CONTROL_MAIL_RUNTIME_CAPABILITY + ) + const task = createHomeTask() + const started = await homeDispatcher.dispatch(startRequest(task.id)) + expect(started).toMatchObject({ ok: true, result: { state: 'ready' } }) + const dispatch = homeDb.getDispatchContext(task.id)! + + const sent = await homeDispatcher.dispatch({ + id: 'send-control-to-old-worker', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'send-control-to-old-worker-request', + method: 'orchestration.send', + params: { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Continue', + body: 'This worker cannot receive control mail yet.', + type: 'status' + } + }) + + expect(sent).toMatchObject({ + ok: false, + error: { code: 'capability_unsupported' } + }) + expect(homeDb.listPendingFederationRelay(dispatch.id, 'to_worker')).toHaveLength(0) + }) + + it('durably relays remote completion into the home Run and acknowledges it', async () => { + const task = createHomeTask() + const started = await homeDispatcher.dispatch(startRequest(task.id)) + expect(started.ok).toBe(true) + const dispatch = homeDb.getDispatchContext(task.id)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + expect(capability).toBeTruthy() + + const sent = await workerDispatcher.dispatch({ + id: 'rpc_worker_done', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'worker_done_request', + orchestrationCapability: capability, + method: 'orchestration.send', + params: { + from: 'term_windows_worker', + subject: 'Windows audit complete', + body: 'Audited Windows behavior. Found no blocker. Nothing remains.', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded', + filesModified: [] + }) + } + }) + expect(sent).toMatchObject({ + ok: true, + result: { relay: { dispatchId: dispatch.id, accepted: true } } + }) + expect(homeDb.getTask(task.id)?.status).toBe('dispatched') + + await homeRuntime.syncOrchestrationFederation() + + expect(homeDb.getTask(task.id)?.status).toBe('completed') + expect(homeDb.getWorkerDispatch(dispatch.id)?.state).toBe('succeeded') + expect(homeDb.getRunMailboxHistory(task.run_id, 10)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.stringMatching(/^relay_/), + type: 'worker_done', + subject: 'Windows audit complete' + }) + ]) + ) + expect( + workerDb.listFederationRelay({ + dispatchId: dispatch.id, + direction: 'to_home', + afterSequence: 0 + })[0] + ).toMatchObject({ acked_at: expect.any(String) }) + }) + + it('relays a worker question home and the coordinator answer back', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + const ask = workerDispatcher.dispatch({ + id: 'rpc_remote_ask', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'remote_question_request', + orchestrationCapability: capability, + method: 'orchestration.ask', + params: { + from: 'term_windows_worker', + question: 'Should I include slow integration tests?', + options: 'yes,no', + timeoutMs: 60_000 + } + }) + await vi.waitFor(() => + expect( + workerDb.listFederationRelay({ + dispatchId: dispatch.id, + direction: 'to_home', + afterSequence: 0 + }) + ).toHaveLength(1) + ) + + await homeRuntime.syncOrchestrationFederation() + const question = homeDb + .getRunMailboxHistory(task.run_id, 10) + .find((message) => message.type === 'question') + expect(question).toMatchObject({ + body: 'Should I include slow integration tests?' + }) + + const reply = await homeDispatcher.dispatch({ + id: 'rpc_home_reply', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'home_reply_request', + method: 'orchestration.reply', + params: { + id: question!.id, + body: 'yes', + from: 'term_coord' + } + }) + expect(reply).toMatchObject({ ok: true, result: { question: { status: 'answered' } } }) + await homeRuntime.syncOrchestrationFederation() + + await expect(ask).resolves.toMatchObject({ + ok: true, + result: { + answer: 'yes', + messageId: question!.id, + timedOut: false + } + }) + expect( + homeDb.listFederationRelay({ + dispatchId: dispatch.id, + direction: 'to_worker', + afterSequence: 0 + })[0] + ).toMatchObject({ acked_at: expect.any(String) }) + }) + + it('keeps a timed-out remote question resumable', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + const timedOut = await workerDispatcher.dispatch({ + id: 'rpc_remote_ask_timeout', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'remote_question_timeout_request', + orchestrationCapability: capability, + method: 'orchestration.ask', + params: { + from: 'term_windows_worker', + question: 'Resume this later?', + timeoutMs: 1 + } + }) + expect(timedOut).toMatchObject({ + ok: true, + result: { timedOut: true, messageId: expect.stringMatching(/^relay_/) } + }) + const questionId = (timedOut as { result: { messageId: string } }).result.messageId + + await homeRuntime.syncOrchestrationFederation() + await homeDispatcher.dispatch({ + id: 'rpc_home_late_reply', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'home_late_reply_request', + method: 'orchestration.reply', + params: { id: questionId, body: 'yes', from: 'term_coord' } + }) + restartWorkerRuntime() + const resumed = workerDispatcher.dispatch({ + id: 'rpc_remote_ask_resume', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'remote_question_resume_request', + orchestrationCapability: capability, + method: 'orchestration.ask', + params: { from: 'term_windows_worker', resume: questionId, timeoutMs: 5_000 } + }) + await homeRuntime.syncOrchestrationFederation() + + await expect(resumed).resolves.toMatchObject({ + ok: true, + result: { answer: 'yes', messageId: questionId, timedOut: false } + }) + }) + + it('retries a lost relay acknowledgment without duplicating the home message', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + await workerDispatcher.dispatch({ + id: 'rpc_remote_status', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'remote_status_request', + orchestrationCapability: capability, + method: 'orchestration.send', + params: { + from: 'term_windows_worker', + subject: 'Checkpoint', + body: 'One durable update', + type: 'status' + } + }) + loseNextAckResponse = true + + await expect(homeRuntime.syncOrchestrationFederation()).resolves.toBeUndefined() + await homeRuntime.syncOrchestrationFederation() + + expect( + homeDb + .getRunMailboxHistory(task.run_id, 10) + .filter((message) => message.subject === 'Checkpoint') + ).toHaveLength(1) + expect(homeDb.getFederatedDispatch(dispatch.id)?.to_home_imported_sequence).toBe(1) + }) + + it('rejects a reordered relay gap, then converges without loss or duplication', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + + expect(() => + homeDb.importFederatedRelayItem({ + dispatchId: dispatch.id, + sequence: 2, + message: { + id: 'relay_gap', + runId: task.run_id, + from: `dispatch:${dispatch.id}`, + to: `run:${task.run_id}`, + subject: 'Gap', + body: 'Out of order', + type: 'status', + priority: 'normal' + }, + lifecycle: { kind: 'none' } + }) + ).toThrow(/not contiguous/) + expect(homeDb.getMessageById('relay_gap')).toBeUndefined() + expect(homeDb.getFederatedDispatch(dispatch.id)?.to_home_imported_sequence).toBe(0) + + homeDb.importFederatedRelayItem({ + dispatchId: dispatch.id, + sequence: 1, + message: { + id: 'relay_first', + runId: task.run_id, + from: `dispatch:${dispatch.id}`, + to: `run:${task.run_id}`, + subject: 'First', + body: 'Arrived after the gap was rejected', + type: 'status', + priority: 'normal' + }, + lifecycle: { kind: 'none' } + }) + const recovered = homeDb.importFederatedRelayItem({ + dispatchId: dispatch.id, + sequence: 2, + message: { + id: 'relay_gap', + runId: task.run_id, + from: `dispatch:${dispatch.id}`, + to: `run:${task.run_id}`, + subject: 'Gap', + body: 'Out of order', + type: 'status', + priority: 'normal' + }, + lifecycle: { kind: 'none' } + }) + const duplicate = homeDb.importFederatedRelayItem({ + dispatchId: dispatch.id, + sequence: 2, + message: { + id: 'relay_gap', + runId: task.run_id, + from: `dispatch:${dispatch.id}`, + to: `run:${task.run_id}`, + subject: 'Gap', + body: 'Out of order', + type: 'status', + priority: 'normal' + }, + lifecycle: { kind: 'none' } + }) + + expect(recovered.duplicate).toBe(false) + expect(duplicate.duplicate).toBe(true) + expect(homeDb.getFederatedDispatch(dispatch.id)?.to_home_imported_sequence).toBe(2) + expect( + homeDb + .getRunMailboxHistory(task.run_id, 10) + .filter((message) => ['relay_first', 'relay_gap'].includes(message.id)) + ).toHaveLength(2) + }) + + it('restarts relay polling when a federated worker is shown', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + const prompt = vi.mocked(workerRuntime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + const capability = prompt.match(/--dispatch-capability (dcap_[A-Za-z0-9_-]+)/)?.[1] + homeRuntime.stopOrchestrationFederationRelay() + await workerDispatcher.dispatch({ + id: 'rpc_restart_status', + authToken: 'worker-local-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'restart_status_request', + orchestrationCapability: capability, + method: 'orchestration.send', + params: { + from: 'term_windows_worker', + subject: 'After home restart', + body: 'Relay me after worker-show', + type: 'status' + } + }) + + await homeDispatcher.dispatch({ + id: 'rpc_restart_show', + authToken: 'coordinator-token', + method: 'orchestration.workerShow', + params: { dispatch: dispatch.id } + }) + + await vi.waitFor(() => + expect( + homeDb + .getRunMailboxHistory(task.run_id, 10) + .some((message) => message.subject === 'After home restart') + ).toBe(true) + ) + }) + + it('treats a worker runtime ID change as an epoch, not a new server', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + const oldEpoch = homeDb.getFederatedDispatch(dispatch.id)?.remote_runtime_epoch + restartWorkerRuntime() + + const shown = await homeDispatcher.dispatch({ + id: 'rpc_worker_restart_show', + authToken: 'coordinator-token', + method: 'orchestration.workerShow', + params: { dispatch: dispatch.id } + }) + + expect(shown).toMatchObject({ + ok: true, + result: { observation: { status: 'running', exactWorker: true } } + }) + expect(homeDb.getFederatedDispatch(dispatch.id)?.remote_runtime_epoch).not.toBe(oldEpoch) + expect(homeDb.getFederatedDispatch(dispatch.id)?.peer_fingerprint).toBe( + 'windows_peer_fingerprint' + ) + }) + + it('stops only the exact remote agent terminal', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + + const stopped = await homeDispatcher.dispatch({ + id: 'rpc_remote_stop', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_remote_stop', + method: 'orchestration.workerStop', + params: { dispatch: dispatch.id } + }) + + expect(stopped).toMatchObject({ + ok: true, + result: { state: 'stopped', processAction: 'closed_agent_terminal' } + }) + expect(workerRuntime.closeTerminal).toHaveBeenCalledTimes(1) + expect(workerRuntime.closeTerminal).toHaveBeenCalledWith('term_windows_worker') + expect(homeDb.getTask(task.id)?.status).toBe('blocked') + + vi.mocked(workerRuntime.showTerminal).mockResolvedValue({ + handle: 'term_windows_worker', + worktreeId: 'repo::windows-worktree', + connected: false, + writable: false + } as never) + const shown = await homeDispatcher.dispatch({ + id: 'rpc_remote_show_after_stop', + authToken: 'coordinator-token', + method: 'orchestration.workerShow', + params: { dispatch: dispatch.id } + }) + expect(shown).toMatchObject({ + ok: true, + result: { observation: { status: 'exited', exactWorker: true } } + }) + }) + + it('rejects a re-paired server before show or stop effects', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + workerPeerFingerprint = 'replacement_windows_peer' + + const shown = await homeDispatcher.dispatch({ + id: 'rpc_changed_peer_show', + authToken: 'coordinator-token', + method: 'orchestration.workerShow', + params: { dispatch: dispatch.id } + }) + const stopped = await homeDispatcher.dispatch({ + id: 'rpc_changed_peer_stop', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_changed_peer_stop', + method: 'orchestration.workerStop', + params: { dispatch: dispatch.id } + }) + + expect(shown).toMatchObject({ ok: false, error: { code: 'peer_changed' } }) + expect(stopped).toMatchObject({ ok: false, error: { code: 'peer_changed' } }) + expect(homeDb.getWorkerDispatch(dispatch.id)?.state).toBe('ready') + expect(workerRuntime.closeTerminal).not.toHaveBeenCalled() + }) + + it('coalesces overlapping relay polls for the same Dispatch', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + await homeRuntime.syncOrchestrationFederation() + homeRuntime.stopOrchestrationFederationRelay() + + let releasePull!: () => void + const blockedPull = new Promise((resolve) => { + releasePull = resolve + }) + let pullCount = 0 + vi.spyOn(homeRuntime, 'callOrchestrationWorkerServer').mockImplementation( + async (_selector, method) => { + if (method !== 'orchestration.federationPull') { + throw new Error(`Unexpected relay method ${method}`) + } + pullCount += 1 + await blockedPull + return { runtimeEpoch: workerRuntime.getRuntimeId(), items: [] } + } + ) + + const first = homeRuntime.syncOrchestrationFederation() + const second = homeRuntime.syncOrchestrationFederation() + await vi.waitFor(() => expect(pullCount).toBe(1)) + releasePull() + await Promise.all([first, second]) + + expect(pullCount).toBe(1) + }) + + it('warns once while a federated Dispatch remains unreachable', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + await homeRuntime.syncOrchestrationFederation() + homeRuntime.stopOrchestrationFederationRelay() + vi.spyOn(homeRuntime, 'callOrchestrationWorkerServer').mockRejectedValue( + new Error('worker server offline') + ) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + + await homeRuntime.syncOrchestrationFederation() + await homeRuntime.syncOrchestrationFederation() + + expect(warn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('Federation sync failed'), + expect.any(Error) + ) + warn.mockRestore() + }) + + it('returns stop_unknown when the worker server disconnects after the home fence', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + vi.spyOn(homeRuntime, 'callOrchestrationWorkerServer').mockRejectedValueOnce( + new Error('connection lost') + ) + + const stopped = await homeDispatcher.dispatch({ + id: 'rpc_disconnected_stop', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_disconnected_stop', + method: 'orchestration.workerStop', + params: { dispatch: dispatch.id } + }) + + expect(stopped).toMatchObject({ + ok: true, + result: { state: 'stop_unknown', processAction: 'unknown' } + }) + expect(homeDb.getTask(task.id)?.status).toBe('blocked') + expect(workerRuntime.closeTerminal).not.toHaveBeenCalled() + }) + + it('never reads or closes a same-looking replacement process', async () => { + const task = createHomeTask() + await homeDispatcher.dispatch(startRequest(task.id)) + const dispatch = homeDb.getDispatchContext(task.id)! + vi.mocked(workerRuntime.getTerminalProcessIncarnation).mockReturnValue( + 'windows_runtime:pty:replacement' + ) + + const read = await homeDispatcher.dispatch({ + id: 'rpc_replacement_read', + authToken: 'coordinator-token', + method: 'orchestration.workerRead', + params: { dispatch: dispatch.id } + }) + const stopped = await homeDispatcher.dispatch({ + id: 'rpc_replacement_stop', + authToken: 'coordinator-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'request_replacement_stop', + method: 'orchestration.workerStop', + params: { dispatch: dispatch.id } + }) + + expect(read).toMatchObject({ + ok: false, + error: { code: 'worker_identity_changed' } + }) + expect(stopped).toMatchObject({ + ok: true, + result: { state: 'stop_unknown', processAction: 'none' } + }) + expect(workerRuntime.closeTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-federation.ts b/src/main/runtime/rpc/methods/orchestration-federation.ts new file mode 100644 index 00000000000..b8a130adb79 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-federation.ts @@ -0,0 +1,297 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { defineMethod, type RpcMethod } from '../core' +import { assertOrchestrationWorktreeCreationSupported } from './orchestration-folder-worktree-placement' +import { + appendFederationSetupEffect, + appendFederationTerminalEffects, + type FederationEffect +} from './orchestration-federation-effects' +import type { WorkerSetupReceipt } from './orchestration-worker-topology' +import { + monitorFederatedSetup, + persistFederatedReadinessStage, + persistFederatedSetupSpawnFailure, + persistFederatedSetupWaitOutcome +} from './orchestration-federation-setup' +import { FederationAttachStartParams } from './orchestration-federation-start-schema' +import { failFederatedAttachmentWithReceipt } from './orchestration-federation-start-receipt' + +export const ORCHESTRATION_FEDERATION_ATTACH_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.federationAttachStart', + params: FederationAttachStartParams, + handler: async (params, { runtime, orchestrationMutation }) => { + if (!orchestrationMutation) { + throw new OrchestrationError( + 'invalid_argument', + 'Federated worker attachment requires a durable retry request.' + ) + } + if (params.worktree === 'current' || params.worktree === 'new-child') { + throw new OrchestrationError( + 'invalid_argument', + 'A remote worker requires an exact existing worktree or new-top-level.' + ) + } + const createsWorktree = params.worktree === 'new-top-level' + if (createsWorktree && (!params.name || !params.repo)) { + throw new OrchestrationError( + 'invalid_argument', + 'A remote new-top-level worktree requires --name and an explicit --repo.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with remote new-worktree creation.' + ) + } + if ( + !createsWorktree && + (params.name || params.repo || params.baseBranch || params.setup || params.setupSource) + ) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to remote new-top-level worktrees.' + ) + } + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + const agent = params.agent + if (!params.terminal && (!agent || !isTuiAgent(agent))) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A configured --agent is required when federated worker-start creates a terminal.' + ) + } + if (agent) { + runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) + } + if (createsWorktree) { + await assertOrchestrationWorktreeCreationSupported({ + runtime, + repoSelector: params.repo as string, + existingPlacement: 'an exact existing folder workspace' + }) + } + + const db = runtime.getOrchestrationDb() + db.createRemoteDispatchAttachment({ + dispatchId: params.dispatchId, + taskId: params.taskId, + homePeerFingerprint: orchestrationMutation.callerFingerprint, + protocolVersion: params.protocolVersion, + runtimeEpoch: runtime.getRuntimeId(), + mutationReceipt: orchestrationMutation + }) + const effects: FederationEffect[] = [] + let failedStage = createsWorktree ? 'worktree_create' : 'worktree_resolve' + let worktree + let terminalHandle = params.terminal + const setupSource = createsWorktree + ? (params.setupSource ?? (params.setup ? 'explicit_request' : 'orchestration_default')) + : 'existing_worktree' + let setup: WorkerSetupReceipt = { + requested: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', + effective: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', + source: setupSource, + hookFound: false, + startupPolicy: 'start-immediately', + state: createsWorktree ? 'not_configured' : 'not_applicable' + } + try { + if (createsWorktree) { + db.recordRemoteAttachmentStage({ + dispatchId: params.dispatchId, + stage: 'worktree_creating' + }) + const setupDecision = params.setup ?? 'run' + const created = await runtime.createManagedWorktree({ + repoSelector: params.repo as string, + name: params.name as string, + baseBranch: params.baseBranch, + displayName: params.displayName, + comment: params.comment, + runHooks: setupDecision === 'run', + setupDecision, + awaitTerminalProvisioning: true, + observeSetupCompletion: true, + createdWithAgent: agent as TuiAgent, + startupAgent: agent as TuiAgent, + activate: false, + lineage: { noParent: true } + }) + worktree = created.worktree + terminalHandle = created.startupTerminal?.handle + effects.push({ + kind: 'worktree', + action: 'created_top_level', + id: created.worktree.id + }) + setup = { + requested: setupDecision, + effective: setupDecision, + source: setupSource, + hookFound: created.setupReceipt?.hookFound ?? false, + startupPolicy: created.setupReceipt?.startupPolicy ?? 'start-immediately', + state: created.setupReceipt?.state ?? 'not_configured' + } + if (!terminalHandle) { + throw new Error( + created.warning ?? 'Agent-first worktree creation returned no terminal.' + ) + } + const listed = await runtime.listTerminals(`id:${created.worktree.id}`) + appendFederationTerminalEffects( + effects, + listed.terminals, + terminalHandle, + created.setupReceipt?.terminalHandle + ) + appendFederationSetupEffect(effects, setup) + } else { + worktree = await runtime.showManagedWorktree(params.worktree).catch(() => { + throw new OrchestrationError( + 'worktree_not_found_on_server', + `Worktree ${params.worktree} was not found on the selected worker server.` + ) + }) + effects.push( + { kind: 'worktree', action: 'reused', id: worktree.id }, + { kind: 'setup', action: 'not_applicable', state: 'not_applicable' } + ) + if (terminalHandle) { + const terminal = await runtime.showTerminal(terminalHandle) + if (terminal.worktreeId !== worktree.id) { + throw new OrchestrationError( + 'terminal_worktree_mismatch', + `Terminal ${terminalHandle} does not belong to worktree ${worktree.id}.` + ) + } + if (!(await runtime.isTerminalRunningAgent(terminalHandle))) { + throw new OrchestrationError( + 'agent_unconfigured', + `Terminal ${terminalHandle} is not running a recognized agent.` + ) + } + effects.push({ + kind: 'terminal', + role: 'agent', + action: 'reused', + id: terminalHandle + }) + } else { + failedStage = 'terminal_create' + const terminal = await runtime.createTerminal(`id:${worktree.id}`, { + command: agent, + title: `worker-${params.taskId}`, + presentation: 'background' + }) + terminalHandle = terminal.handle + effects.push({ + kind: 'terminal', + role: 'agent', + action: 'created', + id: terminal.handle + }) + } + } + if (!worktree || !terminalHandle) { + throw new Error('Federated worker topology did not resolve.') + } + const setupStage = { + db, + dispatchId: params.dispatchId, + worktreeId: worktree.id, + terminalHandle, + setup, + effects + } + if (persistFederatedSetupSpawnFailure(setupStage)) { + failedStage = 'setup_start' + throw new Error('Setup terminal failed to start before the gated agent launch.') + } + persistFederatedReadinessStage(setupStage) + failedStage = 'agent_readiness' + const wait = await runtime.waitForTerminal(terminalHandle, { + condition: 'tui-idle', + timeoutMs: params.timeoutMs ?? 60_000 + }) + persistFederatedSetupWaitOutcome({ ...setupStage, wait }) + if (!wait.satisfied) { + if (setup.state === 'failed') { + failedStage = 'setup_wait' + } + throw new Error( + wait.blockedReason + ? `Agent startup blocked: ${wait.blockedReason}` + : `Agent did not become ready (${wait.status}).` + ) + } + const paneKey = runtime.getTerminalPaneKey(terminalHandle) + const processIncarnation = runtime.getTerminalProcessIncarnation(terminalHandle) + if (!paneKey || !processIncarnation) { + throw new Error('stable_pane_required') + } + const capability = db.prepareRemoteAttachmentAuthority({ + dispatchId: params.dispatchId, + paneKey, + processIncarnation, + worktreeId: worktree.id, + terminalHandle, + setupState: setup.state, + effects + }) + failedStage = 'dispatch_input' + await runtime.sendTerminalAgentPrompt( + terminalHandle, + buildDispatchPreamble({ + taskId: params.taskId, + dispatchId: params.dispatchId, + taskSpec: params.taskSpec, + coordinatorHandle: 'Run home (relayed by Orca)', + workerHandle: terminalHandle, + dispatchCapability: capability, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) + }) + ) + effects.push({ + kind: 'dispatch_input', + role: 'agent', + id: terminalHandle, + state: 'accepted' + }) + const attachment = db.markRemoteAttachmentReady(params.dispatchId, effects) + monitorFederatedSetup({ ...setupStage, runtime }) + return { + dispatchId: params.dispatchId, + state: attachment.state, + stage: attachment.stage, + runtimeEpoch: runtime.getRuntimeId(), + worktreeId: worktree.id, + terminalHandle, + setup, + effects, + residualResources: [] + } + } catch (error) { + return failFederatedAttachmentWithReceipt({ + db, + dispatchId: params.dispatchId, + runtimeEpoch: runtime.getRuntimeId(), + failedStage, + error, + setup + }) + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-folder-worktree-placement.ts b/src/main/runtime/rpc/methods/orchestration-folder-worktree-placement.ts new file mode 100644 index 00000000000..5f9a65a2390 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-folder-worktree-placement.ts @@ -0,0 +1,17 @@ +import { isFolderRepo } from '../../../../shared/repo-kind' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +export async function assertOrchestrationWorktreeCreationSupported(args: { + runtime: OrcaRuntimeService + repoSelector: string + existingPlacement: string +}): Promise { + if (!isFolderRepo(await args.runtime.showRepo(args.repoSelector))) { + return + } + throw new OrchestrationError( + 'invalid_argument', + `Folder projects cannot create orchestration worktrees; use ${args.existingPlacement}.` + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts new file mode 100644 index 00000000000..ce76d7b611f --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-migration-behavior.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, + ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { RpcDispatcher } from '../dispatcher' +import { ORCHESTRATION_METHODS } from './orchestration' +import { startFederatedWorker } from './orchestration-federated-worker-start' + +describe('orchestration migration behavior', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const database of databases.splice(0)) { + database.close() + } + }) + + function createRuntime(): { db: OrchestrationDb; runtime: OrcaRuntimeService } { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + databases.push(db) + return { db, runtime } + } + + it('lists an explicitly selected legacy Run without binding or mutation', async () => { + const { db, runtime } = createRuntime() + const task = db.createTask({ spec: 'pre-upgrade work' }) + const taskList = ORCHESTRATION_METHODS.find( + (method) => method.name === 'orchestration.taskList' + )! + + const listed = (await taskList.handler(taskList.params!.parse({ run: 'run_legacy_local' }), { + runtime + })) as { + runId: string + legacyReadOnly: boolean + tasks: { id: string }[] + } + + expect(listed).toMatchObject({ + runId: 'run_legacy_local', + legacyReadOnly: true, + tasks: [{ id: task.id }] + }) + expect(db.getTask(task.id)?.status).toBe('ready') + }) + + it('rejects a pre-contract worker_done before message or lifecycle mutation', async () => { + const { db, runtime } = createRuntime() + const run = db.createRun({ + objective: 'legacy worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'legacy worker', runId: run.id }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + + const response = await dispatcher.dispatch({ + id: 'legacy_worker_done', + authToken: 'worker-token', + method: 'orchestration.send', + params: { + from: 'term_worker', + subject: 'done', + type: 'worker_done', + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + } + }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'orchestration_migration_required', + data: { effectsApplied: false } + } + }) + expect(db.getInbox(100)).toHaveLength(0) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') + }) + + it('rejects a connected server missing the contract before home or remote effects', async () => { + const { db, runtime } = createRuntime() + const run = db.createRun({ + objective: 'mixed-version worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer' + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockResolvedValue({ + capabilities: [ORCHESTRATION_FEDERATION_RUNTIME_CAPABILITY] + }) + + await expect( + startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'remote-work', + agent: 'codex' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_start', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + }) + ).rejects.toMatchObject({ + code: 'orchestration_migration_required', + data: { reason: 'runtime_capability_missing', effectsApplied: false } + }) + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('rejects a connected server missing federation support before Task mutation', async () => { + const { db, runtime } = createRuntime() + const run = db.createRun({ + objective: 'unsupported worker', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer' + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockResolvedValue({ + capabilities: [ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY] + }) + + await expect( + startFederatedWorker({ + params: { + task: task.id, + from: 'term_coord', + on: 'windows', + worktree: 'new-top-level', + repo: 'id:windows-repo', + name: 'remote-work', + agent: 'codex' + }, + runtime, + db, + runId: run.id, + task, + orchestrationMutation: { + callerFingerprint: 'caller', + requestId: 'remote_start', + method: 'orchestration.workerStart', + payloadHash: 'payload' + } + }) + ).rejects.toMatchObject({ code: 'capability_unsupported' }) + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-runs.ts b/src/main/runtime/rpc/methods/orchestration-runs.ts new file mode 100644 index 00000000000..498d6228365 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-runs.ts @@ -0,0 +1,100 @@ +import { z } from 'zod' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalString, requiredString } from '../schemas' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' + +const RunCreateParams = z.object({ + objective: requiredString('Missing --objective'), + from: requiredString('Missing coordinator terminal') +}) + +const RunUseParams = z.object({ + id: requiredString('Missing --id'), + from: requiredString('Missing coordinator terminal') +}) + +const RunCurrentParams = z.object({ from: requiredString('Missing coordinator terminal') }) +const RunListParams = z.object({}) +const RunShowParams = z.object({ id: requiredString('Missing --id'), from: OptionalString }) + +function requireCallerPane(runtime: OrcaRuntimeService, handle: string): string { + const paneKey = runtime.getTerminalPaneKey(handle) + if (!paneKey) { + throw new OrchestrationError( + 'stable_pane_required', + 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' + ) + } + return paneKey +} + +export const ORCHESTRATION_RUN_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.runCreate', + params: RunCreateParams, + handler: (params, { runtime }) => { + const paneKey = requireCallerPane(runtime, params.from) + const db = runtime.getOrchestrationDb() + const priorRun = db.getCurrentRunForPane(paneKey) + const run = db.createRun({ + objective: params.objective, + coordinatorHandle: params.from, + coordinatorPaneKey: paneKey + }) + if (priorRun) { + runtime.cancelMessageWaiters(`run:${priorRun.id}`) + } + return { run, binding: { consumerGeneration: run.consumer_generation } } + } + }), + defineMethod({ + name: 'orchestration.runUse', + params: RunUseParams, + handler: (params, { runtime }) => { + const paneKey = requireCallerPane(runtime, params.from) + const db = runtime.getOrchestrationDb() + const priorRun = db.getCurrentRunForPane(paneKey) + const run = db.bindRun({ + runId: params.id, + coordinatorHandle: params.from, + coordinatorPaneKey: paneKey + }) + if (!run) { + throw new OrchestrationError( + 'run_not_found', + `Run ${params.id} was not found or is inspect-only.` + ) + } + runtime.cancelMessageWaiters(`run:${params.id}`) + if (priorRun && priorRun.id !== params.id) { + runtime.cancelMessageWaiters(`run:${priorRun.id}`) + } + return { run, binding: { consumerGeneration: run.consumer_generation } } + } + }), + defineMethod({ + name: 'orchestration.runCurrent', + params: RunCurrentParams, + handler: (params, { runtime }) => { + const paneKey = requireCallerPane(runtime, params.from) + return { run: runtime.getOrchestrationDb().getCurrentRunForPane(paneKey) ?? null } + } + }), + defineMethod({ + name: 'orchestration.runList', + params: RunListParams, + handler: (_params, { runtime }) => ({ runs: runtime.getOrchestrationDb().listRuns() }) + }), + defineMethod({ + name: 'orchestration.runShow', + params: RunShowParams, + handler: (params, { runtime }) => { + const run = runtime.getOrchestrationDb().getRun(params.id) + if (!run) { + throw new OrchestrationError('run_not_found', `Run ${params.id} was not found.`) + } + return { run } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-control.ts b/src/main/runtime/rpc/methods/orchestration-worker-control.ts new file mode 100644 index 00000000000..ae64f967e06 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-control.ts @@ -0,0 +1,294 @@ +import { z } from 'zod' +import { + ORCHESTRATION_WORKER_READ_SOURCES, + type OrchestrationWorkerReadResult +} from '../../../../shared/orchestration-worker-output' +import type { RuntimeTerminalRead } from '../../../../shared/runtime-types' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { syncFederatedDispatch } from '../../orchestration/federation-sync' +import { + createWorkerOutputSourceIdentity, + decodeWorkerOutputCursor, + encodeWorkerOutputCursor +} from '../../orchestration/worker-output-cursor' +import { defineMethod, type RpcMethod } from '../core' +import { OptionalFiniteNumber, requiredString } from '../schemas' +import { + callFederatedWorkerShow, + exposeWorker, + inspectWorkerTerminal, + resolvePinnedFederatedServer +} from './orchestration-worker-observation' +import { readExactWorkerOutput } from './orchestration-worker-output' + +const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) +const WorkerReadParams = WorkerDispatchParams.extend({ + cursor: z.union([z.number().int().nonnegative(), z.string().min(1).max(2_048)]).optional(), + limit: OptionalFiniteNumber, + source: z.enum(ORCHESTRATION_WORKER_READ_SOURCES).optional() +}) + +export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.workerShow', + params: WorkerDispatchParams, + handler: async (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const dispatch = db.getDispatchContextById(params.dispatch) + let worker = db.getWorkerDispatch(params.dispatch) + if (!dispatch || !worker) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker Dispatch ${params.dispatch} was not found.` + ) + } + const federated = db.getFederatedDispatch(params.dispatch) + if (federated) { + const server = resolvePinnedFederatedServer(runtime, federated) + runtime.ensureOrchestrationFederationRelay(dispatch.run_id) + const remote = await callFederatedWorkerShow(runtime, federated) + const attachment = remote.attachment + worker = db.updateWorkerSetupEvidence({ + dispatchId: params.dispatch, + setupState: attachment.setup_state, + effects: attachment.effects + }).worker + if ( + attachment.state === 'succeeded' || + (attachment.state === 'failed' && attachment.stage === 'worker_report_queued') + ) { + await syncFederatedDispatch(runtime, params.dispatch).catch(() => undefined) + } else if ( + attachment.state === 'stopped' && + ['stopping', 'stop_unknown'].includes(worker.state) + ) { + worker = db.reconcileFederatedWorkerStop(params.dispatch) + } else if (['ready', 'failed', 'stopped', 'start_unknown'].includes(attachment.state)) { + worker = db.reconcileFederatedWorkerStart({ + dispatchId: params.dispatch, + state: attachment.state as 'ready' | 'failed' | 'stopped' | 'start_unknown', + stage: attachment.stage, + lastError: attachment.last_error, + worktreeId: attachment.worktree_id, + terminalHandle: attachment.terminal_handle, + setupState: attachment.setup_state, + effects: attachment.effects, + residualResources: attachment.residualResources + }) + if ( + attachment.state === 'ready' && + attachment.worktree_id && + attachment.terminal_handle + ) { + db.updateFederatedDispatchResources({ + dispatchId: params.dispatch, + remoteRuntimeEpoch: remote.runtimeEpoch, + worktreeId: attachment.worktree_id, + terminalHandle: attachment.terminal_handle + }) + } + } + worker = db.getWorkerDispatch(params.dispatch) + if (!worker) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker Dispatch ${params.dispatch} was not found after remote reconciliation.` + ) + } + return { + dispatch: db.getDispatchContextById(params.dispatch), + worker: exposeWorker(worker), + server: { environmentId: server.environmentId, name: server.name }, + remoteRuntimeEpoch: remote.runtimeEpoch, + terminal: remote.terminal, + observation: remote.observation + } + } + if (worker.runtime_epoch && worker.runtime_epoch !== runtime.getRuntimeId()) { + if (worker.state === 'starting') { + worker = db.markWorkerStartUnknown( + params.dispatch, + worker.stage, + 'The runtime restarted before worker-start reached a terminal receipt.' + ) + } else if (worker.state === 'stopping') { + worker = db.markWorkerStopUnknown( + params.dispatch, + 'The runtime restarted before worker-stop reached a terminal receipt.' + ) + } + } + const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) + return { + dispatch, + worker: exposeWorker(worker), + terminal: observation.exact ? observation.terminal : null, + observation: { status: observation.status, exactWorker: observation.exact } + } + } + }), + defineMethod({ + name: 'orchestration.workerRead', + params: WorkerReadParams, + handler: async (params, { runtime }) => { + const db = runtime.getOrchestrationDb() + const federated = db.getFederatedDispatch(params.dispatch) + if (federated) { + const server = resolvePinnedFederatedServer(runtime, federated) + try { + const remote = (await runtime.callOrchestrationWorkerServer( + server.environmentId, + 'orchestration.federationReadOutput', + { + dispatchId: params.dispatch, + cursor: params.cursor, + limit: params.limit, + source: params.source + }, + 15_000 + )) as { runtimeEpoch: string; output: OrchestrationWorkerReadResult } + return { + ...remote.output, + server: { environmentId: server.environmentId, name: server.name }, + remoteRuntimeEpoch: remote.runtimeEpoch + } + } catch (error) { + if (!(error instanceof OrchestrationError) || error.code !== 'method_not_found') { + throw error + } + return readLegacyFederatedTerminal({ + runtime, + server, + federated, + workerState: db.getWorkerDispatch(params.dispatch)?.state ?? 'unknown', + dispatchId: params.dispatch, + source: params.source, + cursor: params.cursor, + limit: params.limit + }) + } + } + const worker = db.getWorkerDispatch(params.dispatch) + if (!worker?.agent_terminal_handle) { + throw new OrchestrationError( + 'dispatch_not_found', + `Worker Dispatch ${params.dispatch} has no agent terminal.` + ) + } + const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) + if (!observation.exact) { + throw new OrchestrationError( + 'worker_identity_changed', + `Worker Dispatch ${params.dispatch} no longer resolves to its exact process.` + ) + } + const output = await readExactWorkerOutput({ + runtime, + dispatchId: params.dispatch, + terminalHandle: worker.agent_terminal_handle, + workerState: worker.state, + terminalStatus: observation.status === 'exited' ? 'exited' : 'running', + attachedAt: worker.created_at, + source: params.source, + cursor: params.cursor, + limit: params.limit + }) + const afterRead = await inspectWorkerTerminal(runtime, db, params.dispatch) + if (!afterRead.exact) { + throw new OrchestrationError( + 'worker_identity_changed', + `Worker Dispatch ${params.dispatch} changed process while output was read.` + ) + } + return output + } + }), + defineMethod({ + name: 'orchestration.workerAbandon', + params: WorkerDispatchParams, + handler: (params, { runtime }) => { + const abandoned = runtime.getOrchestrationDb().abandonWorkerDispatch(params.dispatch) + const worker = abandoned.worker + if (abandoned.disposition === 'abandoned') { + runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') + } + return { + dispatchId: params.dispatch, + state: worker.state, + alreadySettled: abandoned.disposition !== 'abandoned', + stale: abandoned.disposition === 'stale', + processAction: 'none', + warning: + abandoned.disposition === 'stale' + ? 'The Dispatch is no longer current; no state or process changed.' + : 'Possibly-live resources were retained; no process was stopped or deleted.', + residualResources: JSON.parse(worker.residual_resources) as unknown[] + } + } + }) +] + +async function readLegacyFederatedTerminal(args: { + runtime: Parameters[0] + server: ReturnType + federated: Parameters[1] + workerState: string + dispatchId: string + source: (typeof ORCHESTRATION_WORKER_READ_SOURCES)[number] | undefined + cursor: string | number | undefined + limit: number | undefined +}) { + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + if (args.source === 'transcript' || cursor?.source === 'transcript') { + throw new OrchestrationError( + 'transcript_required', + `Connected server ${args.server.name} does not support structured worker output.`, + { reason: 'remote_capability_unavailable' } + ) + } + const remote = (await args.runtime.callOrchestrationWorkerServer( + args.server.environmentId, + 'orchestration.federationRead', + { + dispatchId: args.dispatchId, + cursor: cursor?.source === 'terminal' ? cursor.position : undefined, + limit: args.limit + }, + 15_000 + )) as { runtimeEpoch: string; terminal: RuntimeTerminalRead } + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'legacy-remote-terminal', + args.federated.peer_fingerprint, + args.dispatchId, + remote.runtimeEpoch + ]) + if ( + cursor?.source === 'terminal' && + cursor.sourceIdentity !== null && + cursor.sourceIdentity !== sourceIdentity + ) { + throw new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) + } + const nextPosition = + remote.terminal.nextCursor !== null && /^\d+$/.test(remote.terminal.nextCursor) + ? Number.parseInt(remote.terminal.nextCursor, 10) + : null + return { + dispatchId: args.dispatchId, + source: 'terminal' as const, + sourceIdentity, + terminal: remote.terminal, + cursor: + nextPosition === null + ? null + : encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, nextPosition), + status: { worker: args.workerState, terminal: remote.terminal.status }, + fallbackReason: 'remote_capability_unavailable' as const, + warnings: [], + server: { environmentId: args.server.environmentId, name: args.server.name }, + remoteRuntimeEpoch: remote.runtimeEpoch + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-methods.ts b/src/main/runtime/rpc/methods/orchestration-worker-methods.ts new file mode 100644 index 00000000000..341521323fd --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-methods.ts @@ -0,0 +1,10 @@ +import type { RpcMethod } from '../core' +import { ORCHESTRATION_WORKER_CONTROL_METHODS } from './orchestration-worker-control' +import { ORCHESTRATION_WORKER_STOP_METHODS } from './orchestration-worker-stop' +import { ORCHESTRATION_WORKER_START_METHODS } from './orchestration-workers' + +export const ORCHESTRATION_WORKER_METHODS: RpcMethod[] = [ + ...ORCHESTRATION_WORKER_START_METHODS, + ...ORCHESTRATION_WORKER_CONTROL_METHODS, + ...ORCHESTRATION_WORKER_STOP_METHODS +] diff --git a/src/main/runtime/rpc/methods/orchestration-worker-observation.ts b/src/main/runtime/rpc/methods/orchestration-worker-observation.ts new file mode 100644 index 00000000000..7148fe32dcc --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-observation.ts @@ -0,0 +1,82 @@ +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { FederatedDispatchRow, WorkerDispatchRow } from '../../orchestration/types' + +export async function inspectWorkerTerminal( + runtime: OrcaRuntimeService, + db: OrchestrationDb, + dispatchId: string +): Promise<{ + terminal: Awaited> | null + exact: boolean + status: 'unattached' | 'missing' | 'identity_changed' | 'running' | 'exited' +}> { + const worker = db.getWorkerDispatch(dispatchId) + if (!worker?.agent_terminal_handle) { + return { terminal: null, exact: false, status: 'unattached' } + } + const terminal = await runtime.showTerminal(worker.agent_terminal_handle).catch(() => null) + if (!terminal) { + return { terminal: null, exact: false, status: 'missing' } + } + const exact = db.isDispatchProcessCurrent({ + dispatchId, + paneKey: runtime.getTerminalPaneKey(worker.agent_terminal_handle), + processIncarnation: runtime.getTerminalProcessIncarnation(worker.agent_terminal_handle) + }) + return { + terminal, + exact, + status: exact ? (terminal.connected === false ? 'exited' : 'running') : 'identity_changed' + } +} + +export function exposeWorker(worker: WorkerDispatchRow) { + return { + ...worker, + effects: JSON.parse(worker.effects) as unknown[], + residualResources: JSON.parse(worker.residual_resources) as unknown[], + startOptions: JSON.parse(worker.start_options) as unknown + } +} + +export function resolvePinnedFederatedServer( + runtime: OrcaRuntimeService, + federated: FederatedDispatchRow +) { + const server = runtime.resolveOrchestrationWorkerServer(federated.environment_id) + if (server.peerFingerprint !== federated.peer_fingerprint) { + throw new OrchestrationError( + 'peer_changed', + `Saved environment ${federated.environment_name} now identifies a different Orca server.` + ) + } + return server +} + +export async function callFederatedWorkerShow( + runtime: OrcaRuntimeService, + federated: FederatedDispatchRow +): Promise<{ + runtimeEpoch: string + attachment: { + state: string + stage: string + last_error: string | null + worktree_id: string | null + terminal_handle: string | null + setup_state: string + effects: unknown[] + residualResources: unknown[] + } + terminal: unknown + observation: { status: string; exactWorker: boolean } +}> { + return (await runtime.callOrchestrationWorkerServer( + federated.environment_id, + 'orchestration.federationShow', + { dispatchId: federated.dispatch_id }, + 15_000 + )) as Awaited> +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts new file mode 100644 index 00000000000..6db96efe33d --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.test.ts @@ -0,0 +1,205 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { readExactWorkerOutput } from './orchestration-worker-output' + +function codexMessage(id: string, text: string): string { + return JSON.stringify({ + type: 'event_msg', + payload: { id, type: 'agent_message', message: text } + }) +} + +describe('exact orchestration worker output', () => { + let directory: string + let transcriptA: string + let transcriptB: string + let providerSession: ReturnType + let runtime: OrcaRuntimeService + const readTerminal = vi.fn() + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-worker-output-')) + transcriptA = join(directory, 'session-a.jsonl') + transcriptB = join(directory, 'session-b.jsonl') + await writeFile(transcriptA, `${codexMessage('a', 'worker A only')}\n`) + await writeFile(transcriptB, `${codexMessage('b', 'worker B only')}\n`) + providerSession = { + paneKey: 'tab:worker', + processIncarnation: 'pty:incarnation-1', + agent: 'codex', + providerSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: transcriptA + }, + observedAt: Date.now() + } + readTerminal.mockReset() + readTerminal.mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['terminal output'], + truncated: false, + nextCursor: '9' + }) + runtime = { + getExactWorkerProviderSession: vi.fn(() => providerSession), + getTerminalProcessIncarnation: vi.fn(() => 'pty:incarnation-1'), + getTerminalPaneKey: vi.fn(() => 'tab:worker'), + readTerminal + } as unknown as OrcaRuntimeService + }) + + afterEach(async () => { + await rm(directory, { recursive: true, force: true }) + }) + + const read = (overrides: Partial[0]> = {}) => + readExactWorkerOutput({ + runtime, + dispatchId: 'dispatch_1', + terminalHandle: 'term_worker', + workerState: 'ready', + terminalStatus: 'running', + attachedAt: '2026-07-24 00:00:00', + ...overrides + }) + + it('reads only the exact pane session and keeps its local path private', async () => { + const result = await read() + + expect(result).toMatchObject({ + source: 'transcript', + provider: 'codex', + transcript: { + messages: [{ id: 'a', blocks: [{ type: 'text', text: 'worker A only' }] }] + } + }) + expect(JSON.stringify(result)).not.toContain(transcriptA) + expect(JSON.stringify(result)).not.toContain('worker B only') + expect(readTerminal).not.toHaveBeenCalled() + }) + + it('reads Grok through the shared Native Chat transcript decoder', async () => { + await writeFile( + transcriptA, + `${JSON.stringify({ + id: 'grok-a', + type: 'assistant', + content: 'Grok worker only' + })}\n` + ) + providerSession = { + ...providerSession!, + agent: 'grok', + providerSession: { + key: 'session_id', + id: 'session-grok', + transcriptPath: transcriptA + } + } + + const result = await read() + + expect(result).toMatchObject({ + source: 'transcript', + provider: 'grok', + transcript: { + messages: [{ role: 'assistant', blocks: [{ type: 'text', text: 'Grok worker only' }] }] + } + }) + expect(readTerminal).not.toHaveBeenCalled() + }) + + it('labels OpenCode as a terminal fallback when no transcript decoder exists', async () => { + const capability = `dcap_${'A'.repeat(43)}` + readTerminal.mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: [`opencode --dispatch-capability ${capability}`], + truncated: false, + nextCursor: '9' + }) + providerSession = { + ...providerSession!, + agent: 'opencode', + providerSession: { + key: 'session_id', + id: 'session-opencode', + transcriptPath: transcriptA + } + } + + const result = await read() + + expect(result).toMatchObject({ + source: 'terminal', + fallbackReason: 'provider_unsupported', + terminal: { tail: ['opencode --dispatch-capability [dispatch capability redacted]'] }, + warnings: ['Dispatch capability tokens were redacted from terminal output.'] + }) + expect(JSON.stringify(result)).not.toContain(capability) + }) + + it('rejects an old cursor after the exact provider session changes', async () => { + const initial = await read() + if (initial.source !== 'transcript') { + throw new Error('Expected transcript output') + } + providerSession = { + ...providerSession!, + providerSession: { + key: 'session_id', + id: 'session-b', + transcriptPath: transcriptB + } + } + + await expect(read({ cursor: initial.cursor })).rejects.toMatchObject({ + code: 'source_changed' + }) + }) + + it('uses a labeled terminal fallback and keeps its cursor pinned', async () => { + providerSession = null + const fallback = await read() + + expect(fallback).toMatchObject({ + source: 'terminal', + fallbackReason: 'session_not_reported', + terminal: { tail: ['terminal output'] } + }) + expect(fallback.cursor).toMatch(/^owr1_/) + + providerSession = { + paneKey: 'tab:worker', + processIncarnation: 'pty:incarnation-1', + agent: 'codex', + providerSession: { + key: 'session_id', + id: 'session-a', + transcriptPath: transcriptA + }, + observedAt: Date.now() + } + await read({ cursor: fallback.cursor ?? undefined }) + + expect(readTerminal).toHaveBeenLastCalledWith('term_worker', { + cursor: 9, + limit: undefined + }) + }) + + it('fails instead of falling back when transcript output is required', async () => { + providerSession = null + + await expect(read({ source: 'transcript' })).rejects.toMatchObject({ + code: 'transcript_required', + data: { reason: 'session_not_reported' } + }) + expect(readTerminal).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-worker-output.ts b/src/main/runtime/rpc/methods/orchestration-worker-output.ts new file mode 100644 index 00000000000..60e3f942839 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-output.ts @@ -0,0 +1,208 @@ +import type { + OrchestrationWorkerReadFallbackReason, + OrchestrationWorkerReadResult, + OrchestrationWorkerReadSource +} from '../../../../shared/orchestration-worker-output' +import type { RuntimeTerminalState } from '../../../../shared/runtime-types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { + createWorkerOutputSourceIdentity, + decodeWorkerOutputCursor, + encodeWorkerOutputCursor +} from '../../orchestration/worker-output-cursor' +import { redactWorkerTerminalLines } from '../../orchestration/worker-transcript-payload' +import { readWorkerTranscript } from '../../orchestration/worker-transcript-read' + +export async function readExactWorkerOutput(args: { + runtime: OrcaRuntimeService + dispatchId: string + terminalHandle: string + workerState: string + terminalStatus: RuntimeTerminalState + attachedAt: string + source?: OrchestrationWorkerReadSource + cursor?: string | number + limit?: number +}): Promise { + const source = args.source ?? 'auto' + const cursor = decodeWorkerOutputCursor(args.cursor, args.dispatchId) + assertCursorSourceMatchesRequest(cursor?.source, source) + + if (cursor?.source === 'terminal' || source === 'terminal') { + return readTerminalOutput(args, cursor) + } + + const observedAfter = orchestrationTimestampToMs(args.attachedAt) + const session = args.runtime.getExactWorkerProviderSession(args.terminalHandle, observedAfter) + if (!session) { + if (cursor?.source === 'transcript') { + throw sourceChanged() + } + return fallbackOrThrow(args, 'session_not_reported') + } + const transcript = await readWorkerTranscript({ + agent: session.agent, + sessionId: session.providerSession.id, + transcriptPath: session.providerSession.transcriptPath, + offset: cursor?.source === 'transcript' ? cursor.position : undefined, + limit: args.limit + }) + if (!transcript.ok) { + if (transcript.reason === 'source_changed') { + throw sourceChanged() + } + if (cursor?.source === 'transcript') { + throw transcriptRequired(args.dispatchId, transcript.reason) + } + return fallbackOrThrow(args, transcript.reason, transcript.warnings) + } + const sourceIdentity = createWorkerOutputSourceIdentity([ + 'transcript', + session.processIncarnation, + session.agent, + session.providerSession.key, + session.providerSession.id, + transcript.filePath + ]) + if (cursor?.source === 'transcript' && cursor.sourceIdentity !== sourceIdentity) { + throw sourceChanged() + } + const sessionAfterRead = args.runtime.getExactWorkerProviderSession( + args.terminalHandle, + observedAfter + ) + if ( + !sessionAfterRead || + sessionAfterRead.processIncarnation !== session.processIncarnation || + sessionAfterRead.agent !== session.agent || + sessionAfterRead.providerSession.key !== session.providerSession.key || + sessionAfterRead.providerSession.id !== session.providerSession.id || + sessionAfterRead.providerSession.transcriptPath !== session.providerSession.transcriptPath + ) { + throw sourceChanged() + } + const nextCursor = encodeWorkerOutputCursor( + args.dispatchId, + 'transcript', + sourceIdentity, + transcript.nextOffset + ) + return { + dispatchId: args.dispatchId, + source: 'transcript', + sourceIdentity, + provider: session.agent, + transcript: { + messages: transcript.messages, + nextCursor, + limited: transcript.limited, + returnedMessageCount: transcript.messages.length + }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: args.terminalStatus }, + fallbackReason: null, + warnings: transcript.warnings + } +} + +async function readTerminalOutput( + args: Parameters[0], + cursor: ReturnType +): Promise { + const processIncarnation = args.runtime.getTerminalProcessIncarnation(args.terminalHandle) + const paneKey = args.runtime.getTerminalPaneKey(args.terminalHandle) + if (!processIncarnation || !paneKey) { + throw new OrchestrationError( + 'worker_identity_changed', + `Worker Dispatch ${args.dispatchId} no longer resolves to its exact process.` + ) + } + const sourceIdentity = createWorkerOutputSourceIdentity(['terminal', processIncarnation, paneKey]) + if ( + cursor?.source === 'terminal' && + cursor.sourceIdentity !== null && + cursor.sourceIdentity !== sourceIdentity + ) { + throw sourceChanged() + } + const terminal = await args.runtime.readTerminal(args.terminalHandle, { + cursor: cursor?.source === 'terminal' ? cursor.position : undefined, + limit: args.limit + }) + const redactedTerminal = redactWorkerTerminalLines(terminal.tail) + const position = + terminal.nextCursor !== null && /^\d+$/.test(terminal.nextCursor) + ? Number.parseInt(terminal.nextCursor, 10) + : null + const nextCursor = + position === null + ? null + : encodeWorkerOutputCursor(args.dispatchId, 'terminal', sourceIdentity, position) + return { + dispatchId: args.dispatchId, + source: 'terminal', + sourceIdentity, + terminal: { ...terminal, tail: redactedTerminal.lines }, + cursor: nextCursor, + status: { worker: args.workerState, terminal: terminal.status }, + fallbackReason: null, + warnings: redactedTerminal.warnings + } +} + +async function fallbackOrThrow( + args: Parameters[0], + reason: OrchestrationWorkerReadFallbackReason, + warnings: string[] = [] +): Promise { + if (args.source === 'transcript') { + throw transcriptRequired(args.dispatchId, reason) + } + const fallback = await readTerminalOutput(args, null) + return fallback.source === 'terminal' + ? { + ...fallback, + fallbackReason: reason, + warnings: [...new Set([...fallback.warnings, ...warnings])] + } + : fallback +} + +function assertCursorSourceMatchesRequest( + cursorSource: 'terminal' | 'transcript' | undefined, + requestedSource: OrchestrationWorkerReadSource +): void { + if (cursorSource && requestedSource !== 'auto' && cursorSource !== requestedSource) { + throw new OrchestrationError( + 'cursor_invalid', + `The worker-read cursor is pinned to ${cursorSource} output.` + ) + } +} + +function orchestrationTimestampToMs(value: string): number { + const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(value) + ? `${value.replace(' ', 'T')}Z` + : value + const parsed = Date.parse(normalized) + return Number.isFinite(parsed) ? parsed : 0 +} + +function sourceChanged(): OrchestrationError { + return new OrchestrationError( + 'source_changed', + 'The worker output source changed. Start a fresh worker-read without the old cursor.' + ) +} + +function transcriptRequired( + dispatchId: string, + reason: OrchestrationWorkerReadFallbackReason +): OrchestrationError { + return new OrchestrationError( + 'transcript_required', + `Structured output is unavailable for Dispatch ${dispatchId}: ${reason}.`, + { reason } + ) +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-setup-gate.ts b/src/main/runtime/rpc/methods/orchestration-worker-setup-gate.ts new file mode 100644 index 00000000000..35dc38d6dd9 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-setup-gate.ts @@ -0,0 +1,67 @@ +import type { OrchestrationDb } from '../../orchestration/db' +import { + applyWaitForSetupOutcome, + type WorkerEffect, + type WorkerSetupReceipt +} from './orchestration-worker-topology' + +function residualWorkerEffects(effects: WorkerEffect[]): WorkerEffect[] { + return effects.filter( + (effect) => effect.action?.startsWith('created') || effect.action === 'reused_agent_terminal' + ) +} + +type WorkerSetupStageArgs = { + db: OrchestrationDb + dispatchId: string + worktreeId: string + terminalHandle: string + setup: WorkerSetupReceipt + effects: WorkerEffect[] +} + +export function persistWorkerReadinessStage(args: WorkerSetupStageArgs): void { + args.db.recordWorkerStage({ + dispatchId: args.dispatchId, + stage: 'terminal_readying', + worktreeId: args.worktreeId, + terminalHandle: args.terminalHandle, + setupState: args.setup.state, + effects: args.effects, + residualResources: residualWorkerEffects(args.effects) + }) +} + +export function persistGatedSetupSpawnFailure(args: WorkerSetupStageArgs): boolean { + if (args.setup.startupPolicy !== 'wait-for-setup' || args.setup.state !== 'spawn_failed') { + return false + } + args.db.recordWorkerStage({ + dispatchId: args.dispatchId, + stage: 'setup_start', + worktreeId: args.worktreeId, + terminalHandle: args.terminalHandle, + setupState: args.setup.state, + effects: args.effects, + residualResources: residualWorkerEffects(args.effects) + }) + return true +} + +export function persistWorkerSetupWaitOutcome( + args: WorkerSetupStageArgs & { wait: { satisfied: boolean; status: string } } +): void { + applyWaitForSetupOutcome(args.setup, args.effects, args.wait) + if (args.setup.startupPolicy !== 'wait-for-setup') { + return + } + args.db.recordWorkerStage({ + dispatchId: args.dispatchId, + stage: args.setup.state === 'failed' ? 'setup_failed' : 'setup_settled', + worktreeId: args.worktreeId, + terminalHandle: args.terminalHandle, + setupState: args.setup.state, + effects: args.effects, + residualResources: residualWorkerEffects(args.effects) + }) +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts new file mode 100644 index 00000000000..167d4610984 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-receipt.ts @@ -0,0 +1,41 @@ +import type { OrchestrationDb } from '../../orchestration/db' +import { + isUnknownWorkerStartOutcome, + type WorkerSetupReceipt +} from './orchestration-worker-topology' + +export function failWorkerStartWithReceipt(args: { + db: OrchestrationDb + runId: string + taskId: string + dispatchId: string + failedStage: string + error: unknown + setup: WorkerSetupReceipt +}): unknown { + const reason = args.error instanceof Error ? args.error.message : String(args.error) + const unknown = isUnknownWorkerStartOutcome(args.error, args.failedStage) + const worker = unknown + ? args.db.markWorkerStartUnknown(args.dispatchId, args.failedStage, reason) + : args.db.failWorkerStart(args.dispatchId, args.failedStage, reason) + return { + runId: args.runId, + taskId: args.taskId, + dispatchId: args.dispatchId, + state: worker.state === 'start_unknown' ? 'outcome_unknown' : worker.state, + stage: worker.stage, + failedStage: args.failedStage, + lastError: reason, + setup: args.setup, + effects: JSON.parse(worker.effects) as unknown[], + residualResources: JSON.parse(worker.residual_resources) as unknown[], + ...(unknown + ? { + nextCommands: [ + `orca orchestration worker-show --dispatch ${args.dispatchId} --json`, + `orca orchestration worker-abandon --dispatch ${args.dispatchId} --json` + ] + } + : {}) + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts new file mode 100644 index 00000000000..d577598c966 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-schema.ts @@ -0,0 +1,23 @@ +import { z } from 'zod' +import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas' + +export const WorkerStartParams = z.object({ + task: requiredString('Missing --task'), + on: OptionalString, + run: OptionalString, + from: requiredString('Missing --from'), + worktree: OptionalString, + name: OptionalString, + repo: OptionalString, + baseBranch: OptionalString, + displayName: OptionalString, + comment: OptionalString, + setup: z.enum(['run', 'skip', 'inherit']).optional(), + terminal: OptionalString, + agent: OptionalString, + retryOf: OptionalString, + timeoutMs: OptionalFiniteNumber, + devMode: z.boolean().optional() +}) + +export type WorkerStartInput = z.infer diff --git a/src/main/runtime/rpc/methods/orchestration-worker-stop.ts b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts new file mode 100644 index 00000000000..91984c03ee5 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-stop.ts @@ -0,0 +1,148 @@ +import { z } from 'zod' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { syncFederatedDispatch } from '../../orchestration/federation-sync' +import { defineMethod, type RpcMethod } from '../core' +import { requiredString } from '../schemas' +import { + inspectWorkerTerminal, + resolvePinnedFederatedServer +} from './orchestration-worker-observation' + +const WorkerDispatchParams = z.object({ dispatch: requiredString('Missing --dispatch') }) + +export const ORCHESTRATION_WORKER_STOP_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.workerStop', + params: WorkerDispatchParams, + handler: async (params, { runtime, orchestrationMutation }) => { + const db = runtime.getOrchestrationDb() + const federated = db.getFederatedDispatch(params.dispatch) + if (federated) { + if (!orchestrationMutation) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote worker-stop requires a durable retry request.' + ) + } + const server = resolvePinnedFederatedServer(runtime, federated) + const begun = db.beginWorkerStop(params.dispatch) + if (begun.disposition === 'already_settled') { + return settledReceipt(params.dispatch, begun.worker.state) + } + try { + const remote = (await runtime.callOrchestrationWorkerServer( + server.environmentId, + 'orchestration.federationStop', + { dispatchId: params.dispatch }, + 30_000, + { orchestrationRequestId: orchestrationMutation.requestId } + )) as RemoteStopReceipt + if (remote.state === 'stopped') { + const worker = db.reconcileFederatedWorkerStop(params.dispatch) + return { + dispatchId: params.dispatch, + state: worker.state, + alreadySettled: remote.alreadySettled, + processAction: remote.processAction, + close: remote.close + } + } + if (remote.state === 'succeeded' || remote.state === 'failed') { + db.resumeFederatedWorkerForTerminalRelay(params.dispatch) + await syncFederatedDispatch(runtime, params.dispatch).catch(() => undefined) + return { + dispatchId: params.dispatch, + state: db.getWorkerDispatch(params.dispatch)?.state ?? remote.state, + alreadySettled: true, + processAction: 'none' + } + } + return unknownReceipt( + params.dispatch, + db.markWorkerStopUnknown( + params.dispatch, + remote.lastError ?? `The worker server returned ${remote.state}.` + ), + remote.processAction + ) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + return unknownReceipt( + params.dispatch, + db.markWorkerStopUnknown(params.dispatch, reason), + 'unknown' + ) + } + } + + const begun = db.beginWorkerStop(params.dispatch) + if (begun.disposition === 'already_settled') { + return settledReceipt(params.dispatch, begun.worker.state) + } + const handle = begun.worker.agent_terminal_handle + if (!handle) { + return unknownReceipt( + params.dispatch, + db.markWorkerStopUnknown(params.dispatch, 'The Dispatch has no recorded agent terminal.'), + 'unknown' + ) + } + const observation = await inspectWorkerTerminal(runtime, db, params.dispatch) + if (!observation.exact || observation.status !== 'running') { + return unknownReceipt( + params.dispatch, + db.markWorkerStopUnknown( + params.dispatch, + `The recorded worker process is ${observation.status}; no terminal was closed.` + ), + 'none' + ) + } + try { + const close = await runtime.closeTerminal(handle) + const worker = db.settleWorkerStop(params.dispatch) + runtime.notifyMessageArrived(`dispatch:${params.dispatch}`, 'status') + return { + dispatchId: params.dispatch, + state: worker.state, + alreadySettled: false, + processAction: 'closed_agent_terminal', + close + } + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + return unknownReceipt( + params.dispatch, + db.markWorkerStopUnknown(params.dispatch, reason), + 'unknown' + ) + } + } + }) +] + +type RemoteStopReceipt = { + state: string + alreadySettled: boolean + processAction: string + close?: unknown + lastError?: string | null +} + +function settledReceipt(dispatchId: string, state: string) { + return { dispatchId, state, alreadySettled: true, processAction: 'none' } +} + +function unknownReceipt( + dispatchId: string, + worker: { state: string; last_error: string | null }, + processAction: string +) { + return { + dispatchId, + state: worker.state, + alreadySettled: false, + processAction, + lastError: worker.last_error + } +} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-topology.ts b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts new file mode 100644 index 00000000000..62ed7816487 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-worker-topology.ts @@ -0,0 +1,228 @@ +import type { TuiAgent } from '../../../../shared/types' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationDb } from '../../orchestration/db' + +export type WorkerEffect = { + kind: 'worktree' | 'terminal' | 'setup' | 'dispatch_input' + action?: string + role?: string + id?: string + state?: string + tabId?: string + leafId?: string + requested?: string + effective?: string + source?: string + hookFound?: boolean + startupPolicy?: string + terminalId?: string +} + +export type WorkerSetupReceipt = { + requested: 'run' | 'skip' | 'inherit' | 'not_applicable' + effective: 'run' | 'skip' | 'inherit' | 'not_applicable' + source: string + hookFound: boolean + startupPolicy: 'start-immediately' | 'wait-for-setup' + state: + | 'running' + | 'succeeded' + | 'failed' + | 'skipped' + | 'not_configured' + | 'spawn_failed' + | 'not_applicable' +} + +export function applyWaitForSetupOutcome( + receipt: WorkerSetupReceipt, + effects: WorkerEffect[], + wait: { satisfied: boolean; status: string } +): void { + if (receipt.startupPolicy !== 'wait-for-setup' || receipt.state !== 'running') { + return + } + if (wait.satisfied) { + receipt.state = 'succeeded' + } else if (wait.status === 'exited') { + receipt.state = 'failed' + } else { + return + } + const setupEffect = effects.find((effect) => effect.kind === 'setup') + if (setupEffect) { + setupEffect.state = receipt.state + } +} + +export async function createWorkerWorktree(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatchId: string + requestedWorktree: string + coordinatorWorktree: Awaited> + params: { + repo?: string + name?: string + baseBranch?: string + displayName?: string + comment?: string + setup?: 'run' | 'skip' | 'inherit' + from: string + } + agent: TuiAgent + effects: WorkerEffect[] +}): Promise<{ + worktree: Awaited> + terminalHandle: string + setupReceipt: WorkerSetupReceipt +}> { + const { runtime, db, dispatchId, requestedWorktree, coordinatorWorktree, params, effects } = args + const setupDecision = params.setup ?? 'run' + db.recordWorkerStage({ dispatchId, stage: 'worktree_creating', effects }) + const created = await runtime.createManagedWorktree({ + repoSelector: params.repo ?? coordinatorWorktree.repoId, + name: params.name as string, + baseBranch: params.baseBranch, + displayName: params.displayName, + comment: params.comment, + runHooks: setupDecision === 'run', + setupDecision, + awaitTerminalProvisioning: true, + observeSetupCompletion: true, + createdWithAgent: args.agent, + startupAgent: args.agent, + activate: false, + lineage: { + parentWorktree: requestedWorktree === 'new-child' ? coordinatorWorktree.id : undefined, + noParent: requestedWorktree === 'new-top-level', + callerTerminalHandle: params.from + } + }) + const terminalHandle = created.startupTerminal?.handle + effects.push({ + kind: 'worktree', + action: requestedWorktree === 'new-child' ? 'created_child' : 'created_top_level', + id: created.worktree.id + }) + db.recordWorkerStage({ + dispatchId, + stage: 'worktree_created', + worktreeId: created.worktree.id, + effects, + residualResources: effects + }) + const setupReceipt = { + requested: setupDecision, + effective: setupDecision, + source: params.setup ? 'explicit_request' : 'orchestration_default', + hookFound: created.setupReceipt?.hookFound ?? false, + startupPolicy: created.setupReceipt?.startupPolicy ?? 'start-immediately', + state: created.setupReceipt?.state ?? 'not_configured' + } + if (!terminalHandle) { + throw new Error(created.warning ?? 'Agent-first worktree creation returned no terminal.') + } + const listed = await runtime.listTerminals(`id:${created.worktree.id}`) + const setupTerminalHandle = created.setupReceipt?.terminalHandle + for (const terminal of listed.terminals) { + effects.push({ + kind: 'terminal', + role: + terminal.handle === terminalHandle + ? 'agent' + : terminal.handle === setupTerminalHandle + ? 'setup' + : 'configured_tab', + action: terminal.handle === terminalHandle ? 'reused_agent_terminal' : 'created', + id: terminal.handle, + tabId: terminal.tabId, + leafId: terminal.leafId + }) + } + const setupTerminal = effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'setup' + ) + effects.push({ + kind: 'setup', + action: setupDecision, + requested: setupReceipt.requested, + effective: setupReceipt.effective, + source: setupReceipt.source, + hookFound: setupReceipt.hookFound, + startupPolicy: setupReceipt.startupPolicy, + state: setupReceipt.state, + terminalId: setupTerminalHandle ?? setupTerminal?.id + }) + return { + worktree: created.worktree as Awaited>, + terminalHandle, + setupReceipt + } +} + +export function monitorWorkerSetup(args: { + runtime: OrcaRuntimeService + db: OrchestrationDb + runId: string + dispatchId: string + setupReceipt: WorkerSetupReceipt + effects: WorkerEffect[] +}): void { + const setupTerminal = args.effects.find( + (effect) => effect.kind === 'terminal' && effect.role === 'setup' && effect.id + ) + if ( + !setupTerminal?.id || + args.setupReceipt.startupPolicy !== 'start-immediately' || + args.setupReceipt.state !== 'running' + ) { + return + } + // Why: setup is intentionally non-gating, but command completion remains durable evidence. + void args.runtime + .waitForSetupTerminalCompletion(setupTerminal.id) + .then((completion) => { + const setupState = completion.exitCode === 0 ? 'succeeded' : 'failed' + const evidence = args.db.updateWorkerSetupEvidence({ + dispatchId: args.dispatchId, + setupState, + effects: args.effects.map((effect) => + effect.kind === 'setup' ? { ...effect, state: setupState } : effect + ) + }) + if (!evidence.changed) { + return + } + const message = args.db.insertMessage({ + runId: args.runId, + from: `dispatch:${args.dispatchId}`, + to: `run:${args.runId}`, + subject: `Setup ${setupState} for worker ${args.dispatchId}`, + type: 'status', + priority: setupState === 'failed' ? 'high' : 'normal', + payload: JSON.stringify({ + dispatchId: args.dispatchId, + setupState, + terminalHandle: setupTerminal.id + }) + }) + args.runtime.notifyMessageArrived(message.to_handle, message.type) + }) + .catch(() => undefined) +} + +export function isUnknownWorkerStartOutcome(error: unknown, stage: string): boolean { + const code = + error && typeof error === 'object' && typeof (error as { code?: unknown }).code === 'string' + ? (error as { code: string }).code + : '' + if (code === 'operation_unknown') { + return true + } + if (stage !== 'worktree_create') { + return false + } + const message = error instanceof Error ? error.message : String(error) + return /connection|disconnect|timed?\s*out|runtime changed|outcome unknown/i.test(message) +} diff --git a/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts new file mode 100644 index 00000000000..0bfadd7d351 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-workers-new-worktree.test.ts @@ -0,0 +1,614 @@ +import { createHash } from 'node:crypto' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { ORCHESTRATION_METHODS } from './orchestration' + +describe('orchestration new-worktree workers', () => { + type CreateWorktreeResult = Awaited> + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + let db: OrchestrationDb + let runtime: OrcaRuntimeService + let runId: string + + beforeEach(() => { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + runId = db.createRun({ + objective: 'Test new-worktree workers', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : handle === 'term_worker' + ? 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: 'term_coord', + worktreeId: 'repo::parent', + status: 'running' + } as never) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::parent', + repoId: 'repo' + } as never) + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ + id: 'repo', + kind: 'git' + } as never) + vi.spyOn(runtime, 'createTerminal') + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [{ handle: 'term_worker', title: 'Codex' }], + totalCount: 1, + truncated: false + } as never) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + vi.spyOn(runtime, 'waitForSetupTerminalCompletion').mockReturnValue( + new Promise(() => undefined) + ) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + }) + + afterEach(() => db.close()) + + async function startWorker(overrides: Record = {}) { + const task = db.createTask({ spec: 'new-worktree task', runId }) + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.workerStart' + ) + if (!method) { + throw new Error('workerStart method is not registered') + } + const params = method.params!.parse({ + task: task.id, + from: 'term_coord', + worktree: 'new-child', + name: 'new-worker', + agent: 'codex', + ...overrides + }) + const result = await method.handler(params, { runtime }) + return { result, task } + } + + function mockCreatedWorktree(options?: { + hookFound?: boolean + startupPolicy?: 'start-immediately' | 'wait-for-setup' + state?: 'running' | 'skipped' | 'not_configured' | 'spawn_failed' + terminals?: { handle: string; title: string }[] + setupTerminalHandle?: string + }) { + const hookFound = options?.hookFound ?? true + const state = options?.state ?? (hookFound ? 'running' : 'not_configured') + vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({ + worktree: { id: 'repo::created', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_worker' }, + setupReceipt: { + requested: state === 'skipped' ? 'skip' : 'run', + hookFound, + startupPolicy: options?.startupPolicy ?? 'start-immediately', + state, + terminalHandle: + options?.setupTerminalHandle ?? + options?.terminals?.find((terminal) => terminal.title === 'Setup')?.handle + } + } as never) + if (options?.terminals) { + vi.mocked(runtime.listTerminals).mockResolvedValue({ + terminals: options.terminals, + totalCount: options.terminals.length, + truncated: false + } as never) + } + } + + it('creates an independent top-level worktree and reuses its agent terminal', async () => { + mockCreatedWorktree() + + const { result } = await startWorker({ worktree: 'new-top-level' }) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + startupAgent: 'codex', + awaitTerminalProvisioning: true, + observeSetupCompletion: true, + lineage: expect.objectContaining({ noParent: true, parentWorktree: undefined }) + }) + ) + expect(result).toMatchObject({ state: 'ready' }) + expect(result).toHaveProperty( + 'effects', + expect.arrayContaining([ + expect.objectContaining({ + kind: 'worktree', + action: 'created_top_level', + id: 'repo::created' + }), + expect.objectContaining({ + kind: 'terminal', + role: 'agent', + action: 'reused_agent_terminal', + id: 'term_worker' + }) + ]) + ) + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + + it('rejects a new worktree for a folder project before creating effects', async () => { + vi.mocked(runtime.showRepo).mockResolvedValue({ + id: 'repo', + kind: 'folder' + } as never) + const createWorktree = vi.spyOn(runtime, 'createManagedWorktree') + const task = db.createTask({ spec: 'folder task', runId }) + const method = ORCHESTRATION_METHODS.find( + (candidate) => candidate.name === 'orchestration.workerStart' + ) + if (!method) { + throw new Error('workerStart method is not registered') + } + + await expect( + method.handler( + method.params!.parse({ + task: task.id, + from: 'term_coord', + worktree: 'new-child', + name: 'folder-worker', + agent: 'codex' + }), + { runtime } + ) + ).rejects.toMatchObject({ + code: 'invalid_argument', + message: + 'Folder projects cannot create orchestration worktrees; use current or an exact existing folder workspace.' + }) + expect(createWorktree).not.toHaveBeenCalled() + expect(db.getTask(task.id)?.status).toBe('ready') + expect(db.getDispatchContext(task.id)).toBeUndefined() + }) + + it('injects the execution host CLI command and Dispatch capability together', async () => { + mockCreatedWorktree() + vi.mocked(runtime.getTerminalOrchestrationCliCommand).mockReturnValue('orca-ide') + + await startWorker({ worktree: 'new-top-level' }) + + const prompt = vi.mocked(runtime.sendTerminalAgentPrompt).mock.calls[0]?.[1] ?? '' + expect(prompt).toContain('orca-ide orchestration send') + expect(prompt).toMatch(/--dispatch-capability dcap_[A-Za-z0-9_-]+/) + expect(prompt).not.toMatch(/(^|\s)orca orchestration send/) + }) + + it('passes exact repo, base, metadata, lineage, and setup choices to worktree creation', async () => { + mockCreatedWorktree({ state: 'skipped' }) + + await startWorker({ + worktree: 'new-top-level', + repo: 'id:repo-explicit', + baseBranch: 'origin/release', + displayName: 'Windows release audit', + comment: 'Created for a supervised audit', + setup: 'skip' + }) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + repoSelector: 'id:repo-explicit', + baseBranch: 'origin/release', + displayName: 'Windows release audit', + comment: 'Created for a supervised audit', + setupDecision: 'skip', + runHooks: false, + lineage: expect.objectContaining({ noParent: true, parentWorktree: undefined }) + }) + ) + }) + + it('reports an absent setup hook as not configured without failing the start', async () => { + mockCreatedWorktree({ hookFound: false }) + + const { result } = await startWorker() + + expect(result).toMatchObject({ + state: 'ready', + setup: { + requested: 'run', + effective: 'run', + source: 'orchestration_default', + hookFound: false, + state: 'not_configured' + } + }) + }) + + it.each([ + ['skip', 'skipped'], + ['inherit', 'not_configured'], + ['run', 'running'] + ] as const)('passes explicit setup=%s through with a truthful receipt', async (setup, state) => { + mockCreatedWorktree({ hookFound: setup === 'run', state }) + + const { result } = await startWorker({ setup }) + + expect(runtime.createManagedWorktree).toHaveBeenCalledWith( + expect.objectContaining({ setupDecision: setup, runHooks: setup === 'run' }) + ) + expect(result).toMatchObject({ + state: 'ready', + setup: { requested: setup, effective: setup, source: 'explicit_request', state } + }) + }) + + it('records a later setup failure without gating a start-immediately worker', async () => { + mockCreatedWorktree({ + terminals: [ + { handle: 'term_worker', title: 'Codex' }, + { handle: 'term_setup', title: 'Setup' } + ] + }) + let finishSetup: ((result: { exitCode: number | null }) => void) | undefined + vi.mocked(runtime.waitForSetupTerminalCompletion).mockImplementation( + async () => + await new Promise((resolve) => { + finishSetup = resolve + }) + ) + + const { result, task } = await startWorker() + const dispatchId = (result as { dispatchId: string }).dispatchId + + expect(result).toMatchObject({ state: 'ready', setup: { state: 'running' } }) + expect( + db.settleWorkerReport({ + taskId: task.id, + dispatchId, + outcome: 'succeeded', + result: '{}' + }) + ).toMatchObject({ action: 'settled' }) + finishSetup?.({ exitCode: 1 }) + await vi.waitFor(() => expect(db.getWorkerDispatch(dispatchId)?.setup_state).toBe('failed')) + expect(db.getWorkerDispatch(dispatchId)).toMatchObject({ + state: 'succeeded', + stage: 'settled', + setup_state: 'failed' + }) + expect(JSON.parse(db.getWorkerDispatch(dispatchId)?.effects ?? '[]')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + ) + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledOnce() + expect(db.getInbox(10).filter((message) => message.run_id === runId)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'status', priority: 'high' })]) + ) + }) + + it('uses the exact setup handle instead of a configured tab title', async () => { + mockCreatedWorktree({ + setupTerminalHandle: 'term_actual_setup', + terminals: [ + { handle: 'term_worker', title: 'Codex' }, + { handle: 'term_configured_setup', title: 'Setup' }, + { handle: 'term_actual_setup', title: 'PowerShell' } + ] + }) + + const { result } = await startWorker() + + expect(result).toMatchObject({ + effects: expect.arrayContaining([ + expect.objectContaining({ + kind: 'terminal', + id: 'term_configured_setup', + role: 'configured_tab' + }), + expect.objectContaining({ kind: 'terminal', id: 'term_actual_setup', role: 'setup' }), + expect.objectContaining({ kind: 'setup', terminalId: 'term_actual_setup' }) + ]) + }) + }) + + it('records wait-for-setup success before task input is accepted', async () => { + mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' }) + + const { result } = await startWorker() + const dispatchId = (result as { dispatchId: string }).dispatchId + + expect(result).toMatchObject({ + state: 'ready', + setup: { startupPolicy: 'wait-for-setup', state: 'succeeded' }, + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'setup', state: 'succeeded' }), + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + }) + expect(vi.mocked(runtime.waitForTerminal).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(runtime.sendTerminalAgentPrompt).mock.invocationCallOrder[0]! + ) + expect(JSON.parse(db.getWorkerDispatch(dispatchId)?.effects ?? '[]')).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + ) + }) + + it('does not inject task input when the gated setup terminal fails to start', async () => { + mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'spawn_failed' }) + vi.mocked(runtime.waitForTerminal).mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: false, + status: 'exited', + exitCode: 1 + }) + + const { result, task } = await startWorker() + + expect(result).toMatchObject({ + state: 'failed', + failedStage: 'setup_start', + setup: { startupPolicy: 'wait-for-setup', state: 'spawn_failed' }, + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'setup', state: 'spawn_failed' }) + ]) + }) + expect(db.getTask(task.id)?.status).toBe('failed') + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('does not inject task input when the gated setup script fails', async () => { + mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' }) + vi.mocked(runtime.waitForTerminal).mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: false, + status: 'exited', + exitCode: 1 + }) + + const { result } = await startWorker() + + expect(result).toMatchObject({ + state: 'failed', + failedStage: 'setup_wait', + setup: { state: 'failed' }, + effects: expect.arrayContaining([expect.objectContaining({ kind: 'setup', state: 'failed' })]) + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('does not mislabel a wait-for-setup timeout as setup failure', async () => { + mockCreatedWorktree({ startupPolicy: 'wait-for-setup', state: 'running' }) + vi.mocked(runtime.waitForTerminal).mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: false, + status: 'running', + exitCode: null + }) + + const { result } = await startWorker() + + expect(result).toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + setup: { state: 'running' } + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('distinguishes no-effect failure, unknown acceptance, and durable residual effects', async () => { + vi.spyOn(runtime, 'createManagedWorktree').mockRejectedValueOnce( + new Error('repository validation failed before creation') + ) + const noEffect = await startWorker({ name: 'no-effect' }) + expect(noEffect.result).toMatchObject({ + state: 'failed', + failedStage: 'worktree_create', + effects: [], + residualResources: [] + }) + + vi.mocked(runtime.createManagedWorktree).mockRejectedValueOnce( + Object.assign(new Error('connection lost after possible acceptance'), { + code: 'operation_unknown' + }) + ) + const unknown = await startWorker({ name: 'unknown-effect' }) + expect(unknown.result).toMatchObject({ + state: 'outcome_unknown', + failedStage: 'worktree_create', + effects: [], + residualResources: [] + }) + + mockCreatedWorktree() + vi.mocked(runtime.waitForTerminal).mockResolvedValueOnce({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: false, + status: 'exited', + exitCode: 1 + }) + const durableEffect = await startWorker({ name: 'durable-effect' }) + expect(durableEffect.result).toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + effects: expect.arrayContaining([ + expect.objectContaining({ kind: 'worktree', id: 'repo::created' }), + expect.objectContaining({ kind: 'terminal', id: 'term_worker' }) + ]), + residualResources: expect.arrayContaining([ + expect.objectContaining({ kind: 'worktree', id: 'repo::created' }), + expect.objectContaining({ kind: 'terminal', id: 'term_worker' }) + ]) + }) + }) + + it('returns outcome unknown when worktree creation may have been accepted remotely', async () => { + vi.spyOn(runtime, 'createManagedWorktree').mockRejectedValue( + Object.assign(new Error('connection closed after request acceptance'), { + code: 'operation_unknown' + }) + ) + + const { result, task } = await startWorker() + + expect(result).toMatchObject({ + state: 'outcome_unknown', + failedStage: 'worktree_create', + nextCommands: expect.arrayContaining([ + expect.stringContaining('worker-show --dispatch'), + expect.stringContaining('worker-abandon --dispatch') + ]) + }) + expect(db.getTask(task.id)?.status).toBe('blocked') + }) + + it('persists the retry request with the starting Dispatch before worktree effects', async () => { + const task = db.createTask({ spec: 'atomic worker acceptance', runId }) + let finishCreate: ((value: CreateWorktreeResult) => void) | undefined + vi.spyOn(runtime, 'createManagedWorktree').mockImplementation( + async () => + await new Promise((resolve) => { + finishCreate = resolve + }) + ) + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const request: RpcRequest = { + id: 'rpc_worker_start', + authToken: 'caller-token', + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'worker_start_request', + method: 'orchestration.workerStart', + params: { + task: task.id, + from: 'term_coord', + worktree: 'new-child', + name: 'atomic-worker', + agent: 'codex' + } + } + + const pending = dispatcher.dispatch(request) + await vi.waitFor(() => expect(db.getDispatchContext(task.id)).toBeDefined()) + const acceptedDispatch = db.getDispatchContext(task.id)! + const callerFingerprint = createHash('sha256').update('caller-token').digest('hex') + const receipt = db.getMutationReceipt(callerFingerprint, 'worker_start_request') + + expect(receipt).toMatchObject({ + request_id: 'worker_start_request', + method: 'orchestration.workerStart', + state: 'pending' + }) + expect(db.getWorkerDispatch(acceptedDispatch.id)).toMatchObject({ + state: 'starting', + stage: 'worktree_creating' + }) + + finishCreate?.({ + worktree: { id: 'repo::created', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_worker' }, + setupReceipt: { + requested: 'run', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_configured' + } + } as CreateWorktreeResult) + await expect(pending).resolves.toMatchObject({ + ok: true, + result: { state: 'ready', mutation: { requestId: 'worker_start_request' } } + }) + expect(db.getMutationReceipt(callerFingerprint, 'worker_start_request')).toMatchObject({ + state: 'completed' + }) + }) + + it('persists pre-effect, post-effect, and post-input stages in order', async () => { + mockCreatedWorktree({ hookFound: false }) + let finishWait: + | ((value: Awaited>) => void) + | undefined + let finishPrompt: + | ((value: Awaited>) => void) + | undefined + vi.mocked(runtime.waitForTerminal).mockImplementationOnce( + async () => + await new Promise((resolve) => { + finishWait = resolve + }) + ) + vi.mocked(runtime.sendTerminalAgentPrompt).mockImplementationOnce( + async () => + await new Promise((resolve) => { + finishPrompt = resolve + }) + ) + + const pending = startWorker({ name: 'staged-worker' }) + await vi.waitFor(() => { + const task = db.listTasks()[0] + const dispatch = task ? db.getDispatchContext(task.id) : undefined + expect(dispatch && db.getWorkerDispatch(dispatch.id)).toMatchObject({ + state: 'starting', + stage: 'terminal_readying', + worktree_id: 'repo::created', + agent_terminal_handle: 'term_worker' + }) + }) + const dispatch = db.getDispatchContext(db.listTasks()[0]!.id)! + expect(JSON.parse(db.getWorkerDispatch(dispatch.id)!.residual_resources)).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 'worktree', id: 'repo::created' })]) + ) + + finishWait?.({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: true, + status: 'running', + exitCode: null + }) + await vi.waitFor(() => + expect(db.getWorkerDispatch(dispatch.id)).toMatchObject({ + state: 'starting', + stage: 'authority_attached' + }) + ) + + finishPrompt?.({ handle: 'term_worker', accepted: true, bytesWritten: 1 }) + await expect(pending).resolves.toMatchObject({ + result: { state: 'ready', stage: 'input_accepted' } + }) + expect(db.getWorkerDispatch(dispatch.id)).toMatchObject({ + state: 'ready', + stage: 'input_accepted' + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts b/src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts new file mode 100644 index 00000000000..880620eee43 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from '../../orca-runtime' +import { OrchestrationDb } from '../../orchestration/db' +import { ORCHESTRATION_METHODS } from './orchestration' + +describe('orchestration worker recovery', () => { + let db: OrchestrationDb + let runtime: OrcaRuntimeService + + beforeEach(() => { + db = new OrchestrationDb(':memory:') + runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue( + 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('runtime:pty:1') + vi.spyOn(runtime, 'showTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + connected: true, + status: 'running' + } as never) + vi.spyOn(runtime, 'readTerminal').mockResolvedValue({ + handle: 'term_worker', + status: 'running', + tail: ['working'], + truncated: false, + nextCursor: null + }) + vi.spyOn(runtime, 'closeTerminal').mockResolvedValue({ + handle: 'term_worker', + closed: true + } as never) + }) + + afterEach(() => db.close()) + + async function call(name: string, params: Record) { + const method = ORCHESTRATION_METHODS.find((candidate) => candidate.name === name) + if (!method) { + throw new Error(`Method not found: ${name}`) + } + return method.handler(method.params!.parse(params), { runtime }) + } + + function createWorker(runtimeEpoch = runtime.getRuntimeId(), ready = true) { + const run = db.createRun({ + objective: 'Recovery', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + const task = db.createTask({ spec: 'recover worker', runId: run.id }) + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + runtimeEpoch + }) + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_worker', + paneKey: 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + processIncarnation: 'runtime:pty:1', + worktreeId: 'repo::worktree', + setupState: 'not_applicable', + effects: [{ kind: 'terminal', action: 'created', id: 'term_worker' }] + }) + if (ready) { + db.markWorkerDispatchReady(started.dispatch.id) + } else { + db.markWorkerStartUnknown(started.dispatch.id, 'dispatch_input', 'connection lost') + } + return { run, task, dispatch: started.dispatch } + } + + it('shows and reads only the exact attached worker process', async () => { + const { dispatch } = createWorker() + + await expect( + call('orchestration.workerShow', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + worker: { state: 'ready' }, + observation: { status: 'running', exactWorker: true }, + terminal: { handle: 'term_worker' } + }) + await expect( + call('orchestration.workerRead', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + dispatchId: dispatch.id, + terminal: { tail: ['working'] } + }) + }) + + it('stops an exact worker whose start receipt is unknown', async () => { + const { task, dispatch } = createWorker(runtime.getRuntimeId(), false) + + await expect( + call('orchestration.workerStop', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + state: 'stopped', + processAction: 'closed_agent_terminal' + }) + expect(runtime.closeTerminal).toHaveBeenCalledWith('term_worker') + expect(db.getTask(task.id)?.status).toBe('blocked') + }) + + it('does not adopt or stop a same-looking pane with a new process incarnation', async () => { + const { task, dispatch } = createWorker() + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime:pty:2') + + await expect( + call('orchestration.workerShow', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + worker: { state: 'ready' }, + observation: { status: 'identity_changed', exactWorker: false }, + terminal: null + }) + await expect(call('orchestration.workerRead', { dispatch: dispatch.id })).rejects.toMatchObject( + { + code: 'worker_identity_changed' + } + ) + await expect( + call('orchestration.workerStop', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + state: 'stop_unknown', + processAction: 'none' + }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getTask(task.id)?.status).toBe('blocked') + }) + + it('labels an exact but disconnected worker as exited and does not close it again', async () => { + const { task, dispatch } = createWorker() + vi.mocked(runtime.showTerminal).mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + connected: false, + writable: false + } as never) + + await expect( + call('orchestration.workerShow', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + observation: { status: 'exited', exactWorker: true }, + terminal: { handle: 'term_worker', connected: false } + }) + await expect( + call('orchestration.workerStop', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + state: 'stop_unknown', + processAction: 'none' + }) + expect(runtime.closeTerminal).not.toHaveBeenCalled() + expect(db.getTask(task.id)?.status).toBe('blocked') + }) + + it('turns an interrupted start into inspectable unknown after runtime restart', async () => { + const run = db.createRun({ + objective: 'Interrupted start', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + const task = db.createTask({ spec: 'interrupted', runId: run.id }) + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + runtimeEpoch: 'previous_runtime' + }) + db.recordWorkerStage({ + dispatchId: started.dispatch.id, + stage: 'worktree_creating' + }) + + await expect( + call('orchestration.workerShow', { dispatch: started.dispatch.id }) + ).resolves.toMatchObject({ + worker: { state: 'start_unknown', stage: 'worktree_creating' }, + observation: { status: 'unattached', exactWorker: false } + }) + expect(db.getTask(task.id)?.status).toBe('blocked') + }) + + it('turns an interrupted stop into unknown after runtime restart', async () => { + const { task, dispatch } = createWorker('previous_runtime') + db.beginWorkerStop(dispatch.id) + + await expect( + call('orchestration.workerShow', { dispatch: dispatch.id }) + ).resolves.toMatchObject({ + worker: { state: 'stop_unknown' } + }) + expect(db.getTask(task.id)?.status).toBe('blocked') + }) + + it('reconciles a stop_unknown Dispatch from an authoritative remote stopped receipt', async () => { + const run = db.createRun({ + objective: 'Lost remote stop response', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + }) + const task = db.createTask({ spec: 'stop remote worker', runId: run.id }) + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + startOptions: {}, + runtimeEpoch: runtime.getRuntimeId(), + federation: { + environmentId: 'environment_windows', + environmentName: 'windows', + peerFingerprint: 'windows_peer', + protocolVersion: 1 + } + }) + db.markWorkerStartUnknown(started.dispatch.id, 'remote_attach', 'response lost') + db.beginWorkerStop(started.dispatch.id) + db.markWorkerStopUnknown(started.dispatch.id, 'stop response lost') + vi.spyOn(runtime, 'resolveOrchestrationWorkerServer').mockReturnValue({ + environmentId: 'environment_windows', + name: 'windows', + peerFingerprint: 'windows_peer' + }) + vi.spyOn(runtime, 'callOrchestrationWorkerServer').mockResolvedValue({ + runtimeEpoch: 'windows_epoch', + attachment: { + state: 'stopped', + stage: 'process_stopped', + last_error: null, + worktree_id: 'repo::windows-worktree', + terminal_handle: 'term_windows_worker', + setup_state: 'running', + effects: [], + residualResources: [] + }, + terminal: { handle: 'term_windows_worker', connected: false }, + observation: { status: 'exited', exactWorker: true } + }) + + await expect( + call('orchestration.workerShow', { dispatch: started.dispatch.id }) + ).resolves.toMatchObject({ + worker: { state: 'stopped', stage: 'process_stopped', last_error: null }, + observation: { status: 'exited', exactWorker: true } + }) + expect(db.getTask(task.id)?.status).toBe('blocked') + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration-workers.ts b/src/main/runtime/rpc/methods/orchestration-workers.ts new file mode 100644 index 00000000000..0b2b19a18e0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-workers.ts @@ -0,0 +1,305 @@ +import { isTuiAgent } from '../../../../shared/tui-agent-config' +import type { TuiAgent } from '../../../../shared/types' +import { buildDispatchPreamble } from '../../orchestration/preamble' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import { defineMethod, type RpcMethod } from '../core' +import { startFederatedWorker } from './orchestration-federated-worker-start' +import { assertOrchestrationWorktreeCreationSupported } from './orchestration-folder-worktree-placement' +import { WorkerStartParams } from './orchestration-worker-start-schema' +import { + createWorkerWorktree, + monitorWorkerSetup, + type WorkerEffect, + type WorkerSetupReceipt +} from './orchestration-worker-topology' +import { + persistGatedSetupSpawnFailure, + persistWorkerReadinessStage, + persistWorkerSetupWaitOutcome +} from './orchestration-worker-setup-gate' +import { failWorkerStartWithReceipt } from './orchestration-worker-start-receipt' + +export const ORCHESTRATION_WORKER_START_METHODS: RpcMethod[] = [ + defineMethod({ + name: 'orchestration.workerStart', + params: WorkerStartParams, + handler: async (params, { runtime, orchestrationMutation }) => { + const db = runtime.getOrchestrationDb() + const coordinatorPane = runtime.getTerminalPaneKey(params.from) + const run = coordinatorPane ? db.getCurrentRunForPane(coordinatorPane) : undefined + if (!run || (params.run && params.run !== run.id)) { + throw new OrchestrationError( + 'consumer_fenced', + 'worker-start requires the coordinator terminal currently bound to the Task Run.' + ) + } + const task = db.getTask(params.task) + if (!task || task.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${params.task} was not found in Run ${run.id}.` + ) + } + + if (params.on) { + return startFederatedWorker({ + params, + runtime, + db, + runId: run.id, + task, + orchestrationMutation + }) + } + + const requestedWorktree = params.worktree ?? 'current' + const createsWorktree = + requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' + if (params.terminal && params.agent) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal reuses an existing agent and cannot combine with --agent.' + ) + } + if (createsWorktree && params.terminal) { + throw new OrchestrationError( + 'invalid_argument', + '--terminal cannot combine with new-worktree creation.' + ) + } + if (createsWorktree && !params.name) { + throw new OrchestrationError('invalid_argument', 'New worktrees require --name.') + } + if (!createsWorktree && (params.name || params.repo || params.baseBranch || params.setup)) { + throw new OrchestrationError( + 'invalid_argument', + 'Creation and setup options apply only to new-child or new-top-level worktrees.' + ) + } + const agent = params.agent + if (!params.terminal && (!agent || !isTuiAgent(agent))) { + throw new OrchestrationError( + 'agent_unconfigured', + 'A configured --agent is required when worker-start creates a terminal.' + ) + } + if (agent) { + runtime.validateOrchestrationAgentLauncher(agent as TuiAgent) + } + + const coordinatorTerminal = await runtime.showTerminal(params.from) + const coordinatorWorktree = await runtime.showManagedWorktree( + `id:${coordinatorTerminal.worktreeId}` + ) + if (createsWorktree) { + await assertOrchestrationWorktreeCreationSupported({ + runtime, + repoSelector: params.repo ?? coordinatorWorktree.repoId, + existingPlacement: 'current or an exact existing folder workspace' + }) + } + let resolvedWorktree = createsWorktree + ? undefined + : requestedWorktree === 'current' + ? coordinatorWorktree + : await runtime.showManagedWorktree(requestedWorktree) + let explicitTerminal + if (params.terminal) { + explicitTerminal = await runtime.showTerminal(params.terminal) + if (explicitTerminal.worktreeId !== resolvedWorktree?.id) { + throw new OrchestrationError( + 'terminal_worktree_mismatch', + `Terminal ${params.terminal} does not belong to worktree ${resolvedWorktree?.id}.` + ) + } + if (!(await runtime.isTerminalRunningAgent(params.terminal))) { + throw new OrchestrationError( + 'agent_unconfigured', + `Terminal ${params.terminal} is not running a recognized agent.` + ) + } + } + + const startOptions = { + worktree: requestedWorktree, + resolvedWorktreeId: resolvedWorktree?.id ?? null, + name: params.name ?? null, + repo: params.repo ?? (createsWorktree ? coordinatorWorktree.repoId : null), + baseBranch: params.baseBranch ?? null, + terminal: params.terminal ?? null, + agent: agent ?? null, + timeoutMs: params.timeoutMs ?? 60_000, + setup: createsWorktree ? (params.setup ?? 'run') : 'not_applicable', + setupSource: createsWorktree + ? params.setup + ? 'explicit_request' + : 'orchestration_default' + : 'existing_worktree' + } + const started = db.createStartingWorkerDispatch({ + taskId: task.id, + retryOf: params.retryOf, + startOptions, + runtimeEpoch: runtime.getRuntimeId(), + mutationReceipt: orchestrationMutation + }) + const effects: WorkerEffect[] = [] + if (resolvedWorktree) { + effects.push( + { kind: 'worktree', action: 'reused', id: resolvedWorktree.id }, + { kind: 'setup', action: 'not_applicable', state: 'not_applicable' } + ) + } + let terminalHandle = params.terminal + let failedStage = 'terminal_create' + let setupReceipt: WorkerSetupReceipt = { + requested: 'not_applicable', + effective: 'not_applicable', + source: 'existing_worktree', + hookFound: false, + startupPolicy: 'start-immediately', + state: 'not_applicable' + } + try { + if (createsWorktree) { + failedStage = 'worktree_create' + const created = await createWorkerWorktree({ + runtime, + db, + dispatchId: started.dispatch.id, + requestedWorktree, + coordinatorWorktree, + params, + agent: agent as TuiAgent, + effects + }) + resolvedWorktree = created.worktree + terminalHandle = created.terminalHandle + setupReceipt = created.setupReceipt + } else if (!terminalHandle) { + db.recordWorkerStage({ + dispatchId: started.dispatch.id, + stage: 'terminal_creating', + worktreeId: resolvedWorktree!.id, + effects + }) + const terminal = await runtime.createTerminal(`id:${resolvedWorktree!.id}`, { + command: agent, + title: `worker-${task.id}`, + presentation: 'background' + }) + terminalHandle = terminal.handle + effects.push({ + kind: 'terminal', + role: 'agent', + action: 'created', + id: terminal.handle + }) + } else { + effects.push({ + kind: 'terminal', + role: 'agent', + action: 'reused', + id: terminalHandle + }) + } + if (!resolvedWorktree || !terminalHandle) { + throw new Error('Worker topology did not resolve an agent terminal and worktree.') + } + const setupStage = { + db, + dispatchId: started.dispatch.id, + worktreeId: resolvedWorktree.id, + terminalHandle, + setup: setupReceipt, + effects + } + if (persistGatedSetupSpawnFailure(setupStage)) { + failedStage = 'setup_start' + throw new Error('Setup terminal failed to start before the gated agent launch.') + } + persistWorkerReadinessStage(setupStage) + + failedStage = 'agent_readiness' + const wait = await runtime.waitForTerminal(terminalHandle, { + condition: 'tui-idle', + timeoutMs: params.timeoutMs ?? 60_000 + }) + persistWorkerSetupWaitOutcome({ ...setupStage, wait }) + if (!wait.satisfied) { + if (setupReceipt.state === 'failed') { + failedStage = 'setup_wait' + } + throw new Error( + wait.blockedReason + ? `Agent startup blocked: ${wait.blockedReason}` + : `Agent did not become ready (${wait.status}).` + ) + } + const paneKey = runtime.getTerminalPaneKey(terminalHandle) + const processIncarnation = runtime.getTerminalProcessIncarnation(terminalHandle) + if (!paneKey || !processIncarnation) { + throw new Error('stable_pane_required') + } + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: terminalHandle, + paneKey, + processIncarnation, + worktreeId: resolvedWorktree.id, + effects, + setupState: setupReceipt.state + }) + + failedStage = 'dispatch_input' + const preamble = buildDispatchPreamble({ + taskId: task.id, + dispatchId: started.dispatch.id, + taskSpec: task.spec, + coordinatorHandle: params.from, + workerHandle: terminalHandle, + dispatchCapability: capability, + devMode: params.devMode, + cliCommand: runtime.getTerminalOrchestrationCliCommand(terminalHandle) + }) + await runtime.sendTerminalAgentPrompt(terminalHandle, preamble) + effects.push({ + kind: 'dispatch_input', + role: 'agent', + id: terminalHandle, + state: 'accepted' + }) + const worker = db.markWorkerDispatchReady(started.dispatch.id, effects) + monitorWorkerSetup({ + runtime, + db, + runId: run.id, + dispatchId: started.dispatch.id, + setupReceipt, + effects + }) + return { + runId: run.id, + taskId: task.id, + dispatchId: started.dispatch.id, + state: worker.state, + stage: worker.stage, + setup: setupReceipt, + timeoutMs: params.timeoutMs ?? 60_000, + effects, + residualResources: [] + } + } catch (error) { + return failWorkerStartWithReceipt({ + db, + runId: run.id, + taskId: task.id, + dispatchId: started.dispatch.id, + failedStage, + error, + setup: setupReceipt + }) + } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 780cb2efd0a..899a94fa190 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -4,12 +4,14 @@ import { ORCHESTRATION_METHODS } from './orchestration' import { RpcDispatcher } from '../dispatcher' import { buildRegistry, type RpcContext, type RpcRequest } from '../core' import { OrchestrationDb } from '../../orchestration/db' +import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' import { OrcaRuntimeService } from '../../orca-runtime' import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types' import { ORCHESTRATION_ASK_MAX_TIMEOUT_MS } from '../../../../shared/orchestration-ask-timeout' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version' function lifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { - return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.` + return `${type} messages belong to one exact Dispatch and cannot target a group address.` } describe('orchestration RPC methods', () => { @@ -17,12 +19,33 @@ describe('orchestration RPC methods', () => { let dbOpen = false let runtime: OrcaRuntimeService let ctx: RpcContext + let activeRunId: string | undefined - function setup(): void { + const coordinatorPaneKey = 'tab_coord:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + + function setup(withBoundRun = true): void { db = new OrchestrationDb(':memory:') dbOpen = true runtime = new OrcaRuntimeService() runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_coord' ? coordinatorPaneKey : null + ) + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) => + handle.startsWith('term_') ? `runtime_test:${handle}:1` : null + ) + if (withBoundRun) { + activeRunId = db.createRun({ + objective: 'Test Run', + coordinatorHandle: 'term_coord', + coordinatorPaneKey + }).id + const createTask = db.createTask.bind(db) + // Why: legacy tests exercise the RPC behavior under test; default their direct fixture rows to the bound Run. + db.createTask = (task) => createTask({ ...task, runId: task.runId ?? activeRunId }) + } else { + activeRunId = undefined + } ctx = { runtime } } @@ -47,17 +70,40 @@ describe('orchestration RPC methods', () => { async function call(name: string, params: Record) { const method = findMethod(name) - const parsed = method.params ? method.params.parse(params) : undefined + const scopedParams = { ...params } + if (activeRunId) { + if (name === 'orchestration.taskCreate' || name === 'orchestration.taskUpdate') { + scopedParams.run ??= activeRunId + scopedParams.callerTerminalHandle ??= 'term_coord' + } else if (name === 'orchestration.taskList') { + scopedParams.run ??= activeRunId + } else if (name === 'orchestration.dispatch') { + scopedParams.run ??= activeRunId + scopedParams.from ??= 'term_coord' + } + } + const parsed = method.params ? method.params.parse(scopedParams) : undefined return method.handler(parsed, ctx) } function makeRequest(method: string, params: Record): RpcRequest { - return { id: 'req_1', authToken: 'token', method, params } + return { + id: 'req_1', + authToken: 'token', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + } } it('registers all expected methods', () => { const registry = buildRegistry(ORCHESTRATION_METHODS) - expect(registry.size).toBe(16) + expect(registry.size).toBe(34) + expect(registry.has('orchestration.runCreate')).toBe(true) + expect(registry.has('orchestration.runUse')).toBe(true) + expect(registry.has('orchestration.runCurrent')).toBe(true) + expect(registry.has('orchestration.runList')).toBe(true) + expect(registry.has('orchestration.runShow')).toBe(true) expect(registry.has('orchestration.send')).toBe(true) expect(registry.has('orchestration.check')).toBe(true) expect(registry.has('orchestration.reply')).toBe(true) @@ -67,6 +113,19 @@ describe('orchestration RPC methods', () => { expect(registry.has('orchestration.taskUpdate')).toBe(true) expect(registry.has('orchestration.dispatch')).toBe(true) expect(registry.has('orchestration.dispatchShow')).toBe(true) + expect(registry.has('orchestration.workerStart')).toBe(true) + expect(registry.has('orchestration.workerShow')).toBe(true) + expect(registry.has('orchestration.workerRead')).toBe(true) + expect(registry.has('orchestration.workerStop')).toBe(true) + expect(registry.has('orchestration.workerAbandon')).toBe(true) + expect(registry.has('orchestration.federationAttachStart')).toBe(true) + expect(registry.has('orchestration.federationPull')).toBe(true) + expect(registry.has('orchestration.federationAck')).toBe(true) + expect(registry.has('orchestration.federationImport')).toBe(true) + expect(registry.has('orchestration.federationShow')).toBe(true) + expect(registry.has('orchestration.federationRead')).toBe(true) + expect(registry.has('orchestration.federationReadOutput')).toBe(true) + expect(registry.has('orchestration.federationStop')).toBe(true) expect(registry.has('orchestration.ask')).toBe(true) expect(registry.has('orchestration.run')).toBe(true) expect(registry.has('orchestration.runStop')).toBe(true) @@ -76,23 +135,207 @@ describe('orchestration RPC methods', () => { expect(registry.has('orchestration.reset')).toBe(true) }) + describe('lightweight Runs', () => { + it('creates and binds a Run to the runtime-resolved caller pane', async () => { + setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue( + 'tab_coord:11111111-1111-4111-8111-111111111111' + ) + + const created = (await call('orchestration.runCreate', { + objective: 'Coordinate reviews', + from: 'term_coord' + })) as { run: { id: string; consumer_generation: number } } + const current = (await call('orchestration.runCurrent', { from: 'term_coord' })) as { + run: { id: string } | null + } + + expect(created.run.consumer_generation).toBe(1) + expect(current.run?.id).toBe(created.run.id) + }) + + it('requires runtime-observed stable pane identity for binding', async () => { + setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue(null) + + await expect( + call('orchestration.runCreate', { objective: 'No pane', from: 'term_stale' }) + ).rejects.toMatchObject({ code: 'stable_pane_required' }) + expect(db.listRuns().filter((run) => run.legacy === 0)).toHaveLength(0) + }) + + it('rebinds explicitly, lists Runs, and keeps the legacy Run inspect-only', async () => { + setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_old' + ? 'tab_old:11111111-1111-4111-8111-111111111111' + : 'tab_new:22222222-2222-4222-9222-222222222222' + ) + const created = (await call('orchestration.runCreate', { + objective: 'Move me', + from: 'term_old' + })) as { run: { id: string } } + const rebound = (await call('orchestration.runUse', { + id: created.run.id, + from: 'term_new' + })) as { run: { consumer_generation: number } } + const listed = (await call('orchestration.runList', {})) as { + runs: { id: string; legacy: number }[] + } + + expect(rebound.run.consumer_generation).toBe(2) + expect(listed.runs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: created.run.id, legacy: 0 }), + expect.objectContaining({ id: 'run_legacy_local', legacy: 1 }) + ]) + ) + await expect( + call('orchestration.runUse', { id: 'run_legacy_local', from: 'term_new' }) + ).rejects.toMatchObject({ code: 'run_not_found' }) + }) + + it('requires an explicit binding before task mutation', async () => { + setup(false) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue(coordinatorPaneKey) + + await expect( + call('orchestration.taskCreate', { + spec: 'must not become global', + callerTerminalHandle: 'term_coord' + }) + ).rejects.toMatchObject({ + code: 'run_required', + data: { + effectsApplied: false, + nextCommandArgs: ['skills', 'get', 'orchestration', '--full'] + } + }) + expect(db.listTasks()).toHaveLength(0) + }) + + it('scopes task listing and fences the old coordinator after run-use', async () => { + setup(false) + const oldPane = 'tab_old:11111111-1111-4111-8111-111111111111' + const newPane = 'tab_new:22222222-2222-4222-9222-222222222222' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_old' ? oldPane : newPane + ) + const runA = db.createRun({ + objective: 'A', + coordinatorHandle: 'term_old', + coordinatorPaneKey: oldPane + }) + const runB = db.createRun({ + objective: 'B', + coordinatorHandle: 'term_other', + coordinatorPaneKey: newPane + }) + const taskA = db.createTask({ spec: 'A work', runId: runA.id }) + db.createTask({ spec: 'B work', runId: runB.id }) + + const listed = (await call('orchestration.taskList', { run: runA.id })) as { + tasks: { id: string }[] + } + expect(listed.tasks.map((task) => task.id)).toEqual([taskA.id]) + + db.bindRun({ + runId: runA.id, + coordinatorHandle: 'term_new', + coordinatorPaneKey: newPane + }) + await expect( + call('orchestration.taskCreate', { + spec: 'stale write', + run: runA.id, + callerTerminalHandle: 'term_old' + }) + ).rejects.toMatchObject({ code: 'consumer_fenced' }) + }) + + it('cancels and fences the old Run waiter when run-use rebinds', async () => { + setup(false) + const oldPane = 'tab_old:11111111-1111-4111-8111-111111111111' + const newPane = 'tab_new:22222222-2222-4222-9222-222222222222' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_old' ? oldPane : newPane + ) + const created = (await call('orchestration.runCreate', { + objective: 'Wait fencing', + from: 'term_old' + })) as { run: { id: string } } + const oldWait = call('orchestration.check', { + terminal: 'term_old', + wait: true, + timeoutMs: 5_000 + }) + const fenced = expect(oldWait).rejects.toMatchObject({ code: 'consumer_fenced' }) + await Promise.resolve() + + await call('orchestration.runUse', { + id: created.run.id, + from: 'term_new' + }) + + await fenced + }) + }) + describe('orchestration.send', () => { it('sends a message', async () => { setup() vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) const result = (await call('orchestration.send', { - from: 'term_a', - to: 'term_b', + from: 'term_coord', + to: `run:${activeRunId}`, subject: 'hello' - })) as { message: { id: string; from_handle: string } } + })) as { message: { id: string; from_handle: string; run_id: string } } expect(result.message.id).toMatch(/^msg_/) - expect(result.message.from_handle).toBe('term_a') - expect(runtime.deliverPendingMessagesForHandle).toHaveBeenCalledWith('term_b') + expect(result.message.from_handle).toBe('term_coord') + expect(result.message.run_id).toBe(activeRunId) + expect(runtime.deliverPendingMessagesForHandle).not.toHaveBeenCalled() }) - it('stores the sender pane key on the message row', async () => { + it('routes exact Dispatch mail independently of terminal handles', async () => { setup() + const task = db.createTask({ spec: 'controlled worker' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + + const result = (await call('orchestration.send', { + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'Pause after this step' + })) as { message: { to_handle: string; run_id: string } } + + expect(result.message).toMatchObject({ + to_handle: `dispatch:${dispatch.id}`, + run_id: activeRunId + }) + + const workerCheck = (await call('orchestration.check', { + terminal: 'term_worker' + })) as { dispatchId: string; messages: { subject: string }[] } + expect(workerCheck).toMatchObject({ + dispatchId: dispatch.id, + messages: [{ subject: 'Pause after this step' }] + }) + }) + + it('rejects hidden task-recipient retargeting', async () => { + setup() + await expect( + call('orchestration.send', { + from: 'term_coord', + to: 'task:task_1', + subject: 'ambiguous' + }) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + }) + + it('stores the runtime-observed sender pane key on the message row', async () => { + setup() + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_runtime:leaf_runtime') vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) @@ -103,7 +346,7 @@ describe('orchestration RPC methods', () => { senderPaneKey: 'tab_a:leaf_a' })) as { message: { id: string } } - expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_a:leaf_a') + expect(db.getMessageById(result.message.id)?.sender_pane_key).toBe('tab_runtime:leaf_runtime') }) it('recovers missing sender pane identity from the resolved handle', async () => { @@ -135,7 +378,11 @@ describe('orchestration RPC methods', () => { to: 'term_coord', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) }) expect(db.getTask(task.id)?.status).toBe('completed') @@ -157,14 +404,18 @@ describe('orchestration RPC methods', () => { to: 'term_coord', subject: 'Done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) }) expect(db.getTask(task.id)?.status).toBe('dispatched') expect(db.getTask(dependent.id)?.status).toBe('pending') }) - it('does not replace a foreign sender pane with its claimed assignee handle pane', async () => { + it('ignores caller-supplied pane claims and uses the runtime-observed pane', async () => { setup() const task = db.createTask({ spec: 'work' }) const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') @@ -178,26 +429,108 @@ describe('orchestration RPC methods', () => { subject: 'Done', type: 'worker_done', senderPaneKey: 'tab_foreign:leaf_foreign', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) })) as { message: { id: string; type: string; subject: string } lifecycle: { action: string; code: string; reason: string } } - expect(db.getTask(task.id)?.status).toBe('dispatched') - expect(result.lifecycle).toMatchObject({ - action: 'rejected', - code: 'sender_not_assignee', - reason: expect.stringContaining('expected handle term_worker') - }) + expect(db.getTask(task.id)?.status).toBe('completed') + expect(result.lifecycle).toBeUndefined() expect(result.message).toMatchObject({ type: 'worker_done', - subject: 'Rejected worker_done: Done' + subject: 'Done' }) - expect(db.getUnreadMessages('term_coord')).toEqual([ + expect(db.getUnreadMessages(`run:${activeRunId}`)).toEqual([ expect.objectContaining({ id: result.message.id, type: 'worker_done' }) ]) - expect(runtime.notifyMessageArrived).toHaveBeenCalledWith('term_coord', 'worker_done') + expect(runtime.notifyMessageArrived).toHaveBeenCalledWith(`run:${activeRunId}`, 'worker_done') + }) + + it('requires the minted capability, exact pane, and process incarnation', async () => { + setup() + const task = db.createTask({ spec: 'capability work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const capability = db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'runtime_test:term_worker:1' + }) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey + ) + const payload = JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) + + const rejected = (await call('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload + })) as { lifecycle: { code: string }; message: { subject: string } } + expect(rejected).toMatchObject({ + lifecycle: { code: 'dispatch_capability_invalid' }, + message: { subject: 'Rejected worker_done: Done' } + }) + expect(db.getTask(task.id)?.status).toBe('dispatched') + + ctx = { runtime, orchestrationCapability: 'dcap_wrong' } + const wrongToken = (await call('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload + })) as { lifecycle: { code: string } } + expect(wrongToken.lifecycle.code).toBe('dispatch_capability_invalid') + + ctx = { runtime, orchestrationCapability: capability } + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_worker' ? 'tab_foreign:leaf_foreign' : coordinatorPaneKey + ) + const wrongPane = (await call('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload + })) as { lifecycle: { code: string } } + expect(wrongPane.lifecycle.code).toBe('dispatch_capability_invalid') + + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey + ) + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:2') + const wrongProcess = (await call('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload + })) as { lifecycle: { code: string } } + expect(wrongProcess.lifecycle.code).toBe('dispatch_capability_invalid') + + vi.mocked(runtime.getTerminalProcessIncarnation).mockReturnValue('runtime_test:term_worker:1') + await call('orchestration.send', { + from: 'term_worker', + subject: 'Done', + type: 'worker_done', + payload + }) + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.capability_revoked_at).toBeTruthy() + + const revoked = (await call('orchestration.send', { + from: 'term_worker', + subject: 'Done again', + type: 'worker_done', + payload + })) as { lifecycle: { code: string } } + expect(revoked.lifecycle.code).toBe('dispatch_capability_invalid') }) it('does not wake waiters for a heartbeat suppressed at send time', async () => { @@ -235,12 +568,12 @@ describe('orchestration RPC methods', () => { payload: JSON.stringify({ dispatchId: dispatch.id }) }) - expect(notify).toHaveBeenCalledWith('term_coord', 'heartbeat') + expect(notify).toHaveBeenCalledWith(`run:${activeRunId}`, 'heartbeat') }) - it('rejects missing --to', () => { + it('allows an omitted recipient so an active Dispatch can default to its Run', () => { const method = findMethod('orchestration.send') - expect(() => method.params!.parse({ subject: 'hi' })).toThrow() + expect(method.params!.parse({ subject: 'hi' })).toMatchObject({ subject: 'hi' }) }) it('rejects missing --subject', () => { @@ -403,18 +736,26 @@ describe('orchestration RPC methods', () => { it('continues to send worker_done to a concrete terminal handle', async () => { setup() + const task = db.createTask({ spec: 'work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') const result = (await call('orchestration.send', { from: 'term_worker', to: 'term_coord', subject: 'done', type: 'worker_done', - payload: JSON.stringify({ taskId: 'task_1', dispatchId: 'ctx_1' }) + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) })) as { message: { to_handle: string; type: string; payload: string | null } } - expect(result.message.to_handle).toBe('term_coord') + expect(result.message.to_handle).toBe(`run:${activeRunId}`) expect(result.message.type).toBe('worker_done') - expect(result.message.payload).toBe(JSON.stringify({ taskId: 'task_1', dispatchId: 'ctx_1' })) + expect(result.message.payload).toBe( + JSON.stringify({ taskId: task.id, dispatchId: dispatch.id, outcome: 'succeeded' }) + ) }) it('fans out @idle to only idle agents', async () => { @@ -545,8 +886,8 @@ describe('orchestration RPC methods', () => { const task = db.createTask({ spec: 'lock-release work' }) const dispatch = db.createDispatchContext(task.id, 'term_worker') - // Why: assert lock is already gone at delivery time, not just after the call. - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => { + // Why: waiter notification must observe the settled Dispatch, not stale lifecycle state. + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => { expect(db.getActiveDispatchForTerminal('term_worker')).toBeUndefined() }) @@ -555,7 +896,11 @@ describe('orchestration RPC methods', () => { to: 'term_coord', subject: 'done', type: 'worker_done', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }) })) as { message: { type: string } } expect(result.message.type).toBe('worker_done') @@ -628,18 +973,21 @@ describe('orchestration RPC methods', () => { if (params.dispatchId !== undefined) { payload.dispatchId = params.dispatchId } + payload.outcome = 'succeeded' if (params.filesModified !== undefined) { payload.filesModified = params.filesModified } - db.insertMessage({ + const message = db.insertMessage({ from: params.from ?? 'term_worker', - to: params.to ?? 'term_coord', + to: params.to ?? `run:${activeRunId}`, subject: 'Done', type: 'worker_done', payload: JSON.stringify(payload), - senderPaneKey: params.senderPaneKey + senderPaneKey: params.senderPaneKey, + runId: activeRunId }) + reconcileLifecycleMessage(db, message) } it('returns unread messages for a terminal', async () => { @@ -655,13 +1003,53 @@ describe('orchestration RPC methods', () => { expect(result.count).toBe(2) }) - it('returns formatted output with --inject', async () => { + it('never mixes two bound Run mailboxes', async () => { + setup(false) + const paneA = 'tab_a:11111111-1111-4111-8111-111111111111' + const paneB = 'tab_b:22222222-2222-4222-9222-222222222222' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_a' ? paneA : paneB + ) + const runA = db.createRun({ + objective: 'A', + coordinatorHandle: 'term_a', + coordinatorPaneKey: paneA + }) + const runB = db.createRun({ + objective: 'B', + coordinatorHandle: 'term_b', + coordinatorPaneKey: paneB + }) + db.insertMessage({ + from: 'worker_a', + to: `run:${runA.id}`, + subject: 'A only', + runId: runA.id + }) + db.insertMessage({ + from: 'worker_b', + to: `run:${runB.id}`, + subject: 'B only', + runId: runB.id + }) + + const inboxA = (await call('orchestration.check', { terminal: 'term_a' })) as { + messages: { subject: string }[] + } + const inboxB = (await call('orchestration.check', { terminal: 'term_b' })) as { + messages: { subject: string }[] + } + expect(inboxA.messages.map((message) => message.subject)).toEqual(['A only']) + expect(inboxB.messages.map((message) => message.subject)).toEqual(['B only']) + }) + + it('returns formatted output with --format', async () => { setup() db.insertMessage({ from: 'a', to: 'b', subject: 'test' }) const result = (await call('orchestration.check', { terminal: 'b', - inject: true + format: true })) as { formatted: string; count: number } expect(result.formatted).toContain('Subject: test') @@ -681,6 +1069,68 @@ describe('orchestration RPC methods', () => { expect(result.count).toBe(1) }) + it('returns typed timeout and rejects a second actionable waiter', async () => { + setup() + vi.spyOn(runtime, 'waitForMessage').mockResolvedValueOnce('timed_out') + + const timedOut = (await call('orchestration.check', { + terminal: 'term_coord', + wait: true, + timeoutMs: 10 + })) as { timedOut: boolean; cancelled: boolean; count: number } + expect(timedOut).toMatchObject({ timedOut: true, cancelled: false, count: 0 }) + + vi.mocked(runtime.waitForMessage).mockResolvedValueOnce('waiter_exists') + await expect( + call('orchestration.check', { + terminal: 'term_coord', + wait: true, + timeoutMs: 10 + }) + ).rejects.toMatchObject({ code: 'waiter_exists' }) + }) + + it('rejects stale Delivery acknowledgment without consuming queued mail', async () => { + setup() + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + subject: 'queued', + runId: activeRunId + }) + + await expect( + call('orchestration.check', { + terminal: 'term_coord', + ack: 'delivery_missing' + }) + ).rejects.toMatchObject({ code: 'stale_delivery' }) + expect(db.getUnreadMessages(`run:${activeRunId}`)).toHaveLength(1) + }) + + it('acknowledges a Run Delivery before returning --peek history', async () => { + setup() + db.insertMessage({ + from: 'worker', + to: `run:${activeRunId}`, + subject: 'queued', + runId: activeRunId + }) + + const first = (await call('orchestration.check', { + terminal: 'term_coord' + })) as { count: number; deliveryId: string } + const peeked = (await call('orchestration.check', { + terminal: 'term_coord', + ack: first.deliveryId, + peek: true + })) as { acknowledged: string | null; count: number } + + expect(first.count).toBe(1) + expect(peeked).toMatchObject({ acknowledged: first.deliveryId, count: 0 }) + expect(db.getUnreadMessages(`run:${activeRunId}`)).toHaveLength(0) + }) + it('reconciles worker_done returned by a waiting manual check', async () => { setup() const { task, dispatch } = createDispatchedTask() @@ -690,6 +1140,7 @@ describe('orchestration RPC methods', () => { dispatchId: dispatch.id, filesModified: ['src/file.ts'] }) + return 'notified' }) const result = (await call('orchestration.check', { @@ -697,13 +1148,13 @@ describe('orchestration RPC methods', () => { wait: true, timeoutMs: 100, types: 'worker_done,escalation,decision_gate' - })) as { count: number; messages: { type: string }[] } + })) as { count: number; messages: { type: string }[]; deliveryId: string } expect(result.count).toBe(1) expect(result.messages[0].type).toBe('worker_done') expect(db.getTask(task.id)?.status).toBe('completed') expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') - expect(db.getUnreadMessages('term_coord')).toHaveLength(0) + expect(db.getUnreadMessages(`run:${activeRunId}`)).toHaveLength(1) const taskList = (await call('orchestration.taskList', {})) as { tasks: { id: string @@ -726,13 +1177,20 @@ describe('orchestration RPC methods', () => { const repeated = (await call('orchestration.check', { terminal: 'term_coord', types: 'worker_done' + })) as { count: number; deliveryId: string } + expect(repeated.count).toBe(1) + expect(repeated.deliveryId).toBe(result.deliveryId) + const acknowledged = (await call('orchestration.check', { + terminal: 'term_coord', + ack: repeated.deliveryId, + types: 'worker_done' })) as { count: number } - expect(repeated.count).toBe(0) + expect(acknowledged.count).toBe(0) expect(db.getTask(task.id)?.completed_at).toBe(completedAt) expect(db.getTask(task.id)?.result).toBe(taskResult) }) - it('keeps check --all read-only for lifecycle messages', async () => { + it('keeps check --all read-only while lifecycle settles at acceptance', async () => { setup() const { task, dispatch } = createDispatchedTask() insertWorkerDone({ taskId: task.id, dispatchId: dispatch.id }) @@ -744,9 +1202,9 @@ describe('orchestration RPC methods', () => { })) as { count: number } expect(result.count).toBe(1) - expect(db.getTask(task.id)?.status).toBe('dispatched') - expect(db.getDispatchContextById(dispatch.id)?.status).toBe('dispatched') - expect(db.getUnreadMessages('term_coord', ['worker_done'])).toHaveLength(1) + expect(db.getTask(task.id)?.status).toBe('completed') + expect(db.getDispatchContextById(dispatch.id)?.status).toBe('completed') + expect(db.getUnreadMessages(`run:${activeRunId}`, ['worker_done'])).toHaveLength(1) }) it('does not complete worker_done missing taskId or dispatchId', async () => { @@ -842,11 +1300,13 @@ describe('orchestration RPC methods', () => { const { task, dispatch } = createDispatchedTask() const msg = db.insertMessage({ from: 'term_worker', - to: 'term_coord', + to: `run:${activeRunId}`, subject: 'alive', type: 'heartbeat', - payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }) + payload: JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + runId: activeRunId }) + reconcileLifecycleMessage(db, msg) const result = (await call('orchestration.check', { terminal: 'term_coord', @@ -999,6 +1459,7 @@ describe('orchestration RPC methods', () => { vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { db.insertMessage({ from: 'a', to: 'b', subject: 'arrived during close' }) abortController.abort() + return 'cancelled' }) const result = (await call('orchestration.check', { @@ -1087,6 +1548,49 @@ describe('orchestration RPC methods', () => { 'Message not found' ) }) + + it('records one idempotent answer from the current Run consumer', async () => { + setup() + const task = db.createTask({ spec: 'question work' }) + const dispatch = db.createDispatchContext(task.id, 'term_worker') + const created = db.createQuestion({ + runId: activeRunId!, + dispatchId: dispatch.id, + askerHandle: 'term_worker', + question: 'Proceed?' + }) + const notify = vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + + const first = (await call('orchestration.reply', { + id: created.message.id, + body: 'Yes', + from: 'term_coord' + })) as { message: { id: string; to_handle: string }; duplicate: boolean } + const repeated = (await call('orchestration.reply', { + id: created.message.id, + body: 'Yes', + from: 'term_coord' + })) as { message: { id: string }; duplicate: boolean } + + expect(first.message.to_handle).toBe(`dispatch:${dispatch.id}`) + expect(first.duplicate).toBe(false) + expect(repeated).toMatchObject({ + message: { id: first.message.id }, + duplicate: true + }) + expect(notify).toHaveBeenCalledWith(`dispatch:${dispatch.id}`, 'status') + expect(db.getQuestion(created.message.id)).toMatchObject({ + status: 'answered', + answer_body: 'Yes' + }) + await expect( + call('orchestration.reply', { + id: created.message.id, + body: 'No', + from: 'term_coord' + }) + ).rejects.toMatchObject({ code: 'answer_conflict' }) + }) }) describe('orchestration.inbox', () => { @@ -1161,6 +1665,9 @@ describe('orchestration RPC methods', () => { it('records the caller terminal handle when creating a task', async () => { setup() + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_creator' ? coordinatorPaneKey : null + ) const result = (await call('orchestration.taskCreate', { spec: 'spawn related workspace', callerTerminalHandle: 'term_creator' @@ -1282,11 +1789,17 @@ describe('orchestration RPC methods', () => { setup() await expect( call('orchestration.taskUpdate', { id: 'task_fake', status: 'completed' }) - ).rejects.toThrow('Task not found') + ).rejects.toThrow('was not found') }) }) describe('orchestration.dispatch', () => { + function provideInjectIdentity(handle = 'term_a'): void { + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((candidate) => + candidate === handle ? `tab_worker:${handle}` : coordinatorPaneKey + ) + } + it('dispatches a task to a terminal', async () => { setup() const task = db.createTask({ spec: 'work' }) @@ -1302,7 +1815,9 @@ describe('orchestration RPC methods', () => { it('records the assignee pane key on the dispatch context', async () => { setup() - vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_w:leaf_w') + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_a' ? 'tab_w:leaf_w' : coordinatorPaneKey + ) const task = db.createTask({ spec: 'work' }) const result = (await call('orchestration.dispatch', { @@ -1329,6 +1844,7 @@ describe('orchestration RPC methods', () => { it('rolls back active dispatch when injection fails', async () => { setup() + provideInjectIdentity() const task = db.createTask({ spec: 'work' }) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockRejectedValue( @@ -1349,6 +1865,7 @@ describe('orchestration RPC methods', () => { it('uses caller-provided dev mode for injected preamble', async () => { setup() + provideInjectIdentity() const task = db.createTask({ spec: 'work' }) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) const send = vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ @@ -1388,6 +1905,7 @@ describe('orchestration RPC methods', () => { it('injects preamble through the agent prompt path instead of raw terminal send', async () => { setup() + provideInjectIdentity() const task = db.createTask({ spec: 'line one\nline two' }) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) const agentPrompt = vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ @@ -1481,6 +1999,303 @@ describe('orchestration RPC methods', () => { }) }) + describe('composed workers', () => { + function mockCurrentWorkerStart(options?: { ready?: boolean }): void { + vi.mocked(runtime.getTerminalPaneKey).mockImplementation((handle) => + handle === 'term_coord' + ? coordinatorPaneKey + : handle === 'term_worker' + ? 'tab_worker:leaf_worker' + : null + ) + vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + vi.spyOn(runtime, 'showTerminal').mockImplementation( + async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never + ) + vi.spyOn(runtime, 'showManagedWorktree').mockResolvedValue({ + id: 'repo::worktree' + } as never) + vi.spyOn(runtime, 'createTerminal').mockResolvedValue({ + handle: 'term_worker', + worktreeId: 'repo::worktree', + title: 'worker' + }) + vi.spyOn(runtime, 'waitForTerminal').mockResolvedValue({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: options?.ready !== false, + status: 'running', + exitCode: null + }) + vi.mocked(runtime.getTerminalProcessIncarnation).mockImplementation((handle) => + handle === 'term_worker' ? 'runtime_test:term_worker:1' : null + ) + vi.spyOn(runtime, 'getTerminalOrchestrationCliCommand').mockReturnValue('orca') + vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ + handle: 'term_worker', + accepted: true, + bytesWritten: 1 + }) + } + + it('starts a fresh agent in the coordinator current worktree', async () => { + setup() + mockCurrentWorkerStart() + const task = db.createTask({ spec: 'implement worker start' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { + dispatchId: string + state: string + effects: { kind: string; role?: string; action?: string; state?: string }[] + } + + expect(result.state).toBe('ready') + expect(result.effects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'worktree', action: 'reused' }), + expect.objectContaining({ kind: 'terminal', role: 'agent', action: 'created' }), + expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' }) + ]) + ) + expect(db.getTask(task.id)?.status).toBe('dispatched') + expect(db.getWorkerDispatch(result.dispatchId)?.state).toBe('ready') + expect(runtime.sendTerminalAgentPrompt).toHaveBeenCalledWith( + 'term_worker', + expect.stringContaining('--dispatch-capability dcap_') + ) + }) + + it('starts a fresh agent in an exact existing worktree without replaying setup', async () => { + setup() + mockCurrentWorkerStart() + const createWorktree = vi.spyOn(runtime, 'createManagedWorktree') + vi.mocked(runtime.showManagedWorktree).mockImplementation( + async (selector) => + ({ + id: selector === 'id:repo::other' ? 'repo::other' : 'repo::worktree', + repoId: 'repo' + }) as never + ) + const task = db.createTask({ spec: 'existing worktree worker' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + worktree: 'id:repo::other', + agent: 'codex' + })) as { state: string; setup: { state: string }; effects: unknown[] } + + expect(result).toMatchObject({ state: 'ready' }) + expect(result.effects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'worktree', action: 'reused', id: 'repo::other' }), + expect.objectContaining({ kind: 'setup', action: 'not_applicable' }) + ]) + ) + expect(runtime.createTerminal).toHaveBeenCalledWith( + 'id:repo::other', + expect.objectContaining({ command: 'codex' }) + ) + expect(createWorktree).not.toHaveBeenCalled() + }) + + it('reuses only an explicitly selected existing agent terminal', async () => { + setup() + mockCurrentWorkerStart() + const createWorktree = vi.spyOn(runtime, 'createManagedWorktree') + vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) + const task = db.createTask({ spec: 'reuse exact worker' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + terminal: 'term_worker' + })) as { state: string; effects: unknown[] } + + expect(result).toMatchObject({ state: 'ready' }) + expect(result.effects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + kind: 'terminal', + role: 'agent', + action: 'reused', + id: 'term_worker' + }) + ]) + ) + expect(runtime.createTerminal).not.toHaveBeenCalled() + expect(createWorktree).not.toHaveBeenCalled() + }) + + it('returns a failed receipt and preserves a created terminal as residual', async () => { + setup() + mockCurrentWorkerStart({ ready: false }) + const task = db.createTask({ spec: 'worker timeout' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { state: string; failedStage: string; residualResources: { id: string }[] } + + expect(result).toMatchObject({ state: 'failed', failedStage: 'agent_readiness' }) + expect(result.residualResources).toEqual([expect.objectContaining({ id: 'term_worker' })]) + expect(db.getTask(task.id)?.status).toBe('failed') + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('returns a no-effect failure when terminal creation fails', async () => { + setup() + mockCurrentWorkerStart() + vi.mocked(runtime.createTerminal).mockRejectedValueOnce(new Error('terminal spawn rejected')) + const task = db.createTask({ spec: 'terminal failure' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { state: string; failedStage: string; residualResources: unknown[] } + + expect(result).toMatchObject({ + state: 'failed', + failedStage: 'terminal_create', + residualResources: [] + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + }) + + it('preserves the exact attached terminal when task input is rejected', async () => { + setup() + mockCurrentWorkerStart() + vi.mocked(runtime.sendTerminalAgentPrompt).mockRejectedValueOnce( + new Error('agent input rejected') + ) + const task = db.createTask({ spec: 'input failure' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { + state: string + failedStage: string + residualResources: { kind: string; id: string }[] + } + + expect(result).toMatchObject({ state: 'failed', failedStage: 'dispatch_input' }) + expect(result.residualResources).toEqual( + expect.arrayContaining([expect.objectContaining({ kind: 'terminal', id: 'term_worker' })]) + ) + }) + + it.each(['codex-update-prompt', 'codex-trust-workspace'] as const)( + 'returns a truthful readiness failure for %s', + async (blockedReason) => { + setup() + mockCurrentWorkerStart() + vi.mocked(runtime.waitForTerminal).mockResolvedValueOnce({ + handle: 'term_worker', + condition: 'tui-idle', + satisfied: false, + status: 'running', + exitCode: null, + blockedReason + }) + const task = db.createTask({ spec: 'blocked startup prompt' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + agent: 'codex' + })) as { state: string; failedStage: string; lastError: string } + + expect(result).toMatchObject({ + state: 'failed', + failedStage: 'agent_readiness', + lastError: `Agent startup blocked: ${blockedReason}` + }) + expect(runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() + } + ) + + it('creates a child worktree agent-first with setup run by default', async () => { + setup() + mockCurrentWorkerStart() + vi.mocked(runtime.showManagedWorktree).mockResolvedValue({ + id: 'repo::parent', + repoId: 'repo' + } as never) + vi.spyOn(runtime, 'showRepo').mockResolvedValue({ + id: 'repo', + kind: 'git' + } as never) + const create = vi.spyOn(runtime, 'createManagedWorktree').mockResolvedValue({ + worktree: { id: 'repo::child', repoId: 'repo' }, + startupTerminal: { spawned: true, handle: 'term_worker' }, + setupReceipt: { + requested: 'run', + hookFound: true, + startupPolicy: 'start-immediately', + state: 'running', + terminalHandle: 'term_setup' + } + } as never) + vi.spyOn(runtime, 'listTerminals').mockResolvedValue({ + terminals: [ + { handle: 'term_worker', title: 'Codex' }, + { handle: 'term_setup', title: 'Setup' }, + { handle: 'term_logs', title: 'Logs' } + ], + totalCount: 3, + truncated: false + } as never) + const task = db.createTask({ spec: 'child worker' }) + + const result = (await call('orchestration.workerStart', { + task: task.id, + from: 'term_coord', + worktree: 'new-child', + name: 'child-worker', + agent: 'codex' + })) as { + state: string + setup: { requested: string; startupPolicy: string; state: string } + effects: { role?: string; action?: string }[] + } + + expect(result).toMatchObject({ + state: 'ready', + setup: { + requested: 'run', + startupPolicy: 'start-immediately', + state: 'running' + } + }) + expect(create).toHaveBeenCalledWith( + expect.objectContaining({ + repoSelector: 'repo', + name: 'child-worker', + runHooks: true, + setupDecision: 'run', + startupAgent: 'codex', + lineage: expect.objectContaining({ parentWorktree: 'repo::parent', noParent: false }) + }) + ) + expect(result.effects).toEqual( + expect.arrayContaining([ + expect.objectContaining({ role: 'agent', action: 'reused_agent_terminal' }), + expect.objectContaining({ role: 'setup', action: 'created' }), + expect.objectContaining({ role: 'configured_tab', action: 'created' }) + ]) + ) + expect(runtime.createTerminal).not.toHaveBeenCalled() + }) + }) + describe('orchestration.dispatchShow', () => { it('shows dispatch context for a task', async () => { setup() @@ -1643,33 +2458,38 @@ describe('orchestration RPC methods', () => { }) describe('orchestration.ask', () => { - it('sends a decision_gate and returns the first thread reply', async () => { + function createAskingDispatch(handle = 'term_worker') { + const task = db.createTask({ spec: 'question work' }) + const dispatch = db.createDispatchContext(task.id, handle) + return { task, dispatch } + } + + it('persists a Run question and returns its first durable answer', async () => { setup() - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + const { dispatch } = createAskingDispatch() vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { - // Simulate coordinator replying in the thread during the wait - const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + const outbound = db.getInbox(10).find((message) => message.type === 'question') if (outbound) { - db.insertMessage({ - from: 'term_coord', - to: 'term_worker', - subject: 'Re: Question', - body: 'go ahead', - threadId: outbound.id + db.answerQuestion({ + messageId: outbound.id, + runId: activeRunId!, + consumerGeneration: db.getRun(activeRunId!)!.consumer_generation, + body: 'go ahead' }) } + return 'notified' }) const result = (await call('orchestration.ask', { from: 'term_worker', - to: 'term_coord', question: 'proceed?', options: 'yes, no', timeoutMs: 500 })) as { answer: string messageId: string + answerMessageId: string threadId: string timedOut: boolean } @@ -1678,45 +2498,119 @@ describe('orchestration RPC methods', () => { expect(result.answer).toBe('go ahead') expect(result.messageId).toMatch(/^msg_/) - // Outbound decision_gate message was persisted with parsed options. - const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + const outbound = db.getInbox(10).find((message) => message.type === 'question') expect(outbound).toBeTruthy() + expect(outbound?.to_handle).toBe(`run:${activeRunId}`) expect(outbound?.subject).toBe('Question') expect(outbound?.body).toBe('proceed?') const payload = JSON.parse(outbound!.payload ?? '{}') expect(payload.question).toBe('proceed?') expect(payload.options).toEqual(['yes', 'no']) + expect(db.getQuestion(outbound!.id)).toMatchObject({ + dispatch_id: dispatch.id, + status: 'answered', + answer_body: 'go ahead' + }) + expect(db.getMessageById(result.answerMessageId)).toMatchObject({ + to_handle: `dispatch:${dispatch.id}`, + read: 1 + }) + await expect(call('orchestration.check', { terminal: 'term_worker' })).resolves.toMatchObject( + { count: 0, messages: [] } + ) + }) + + it('requires the Dispatch capability before creating a question', async () => { + setup() + const { dispatch } = createAskingDispatch() + const capability = db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'runtime_test:term_worker:1' + }) + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_worker' ? 'tab_worker:leaf_worker' : coordinatorPaneKey + ) + + await expect( + call('orchestration.ask', { + from: 'term_worker', + question: 'unauthorized', + timeoutMs: 1 + }) + ).rejects.toMatchObject({ code: 'dispatch_capability_invalid' }) + expect(db.getInbox(100).filter((message) => message.type === 'question')).toHaveLength(0) + + ctx = { runtime, orchestrationCapability: capability } + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') + const accepted = (await call('orchestration.ask', { + from: 'term_worker', + question: 'authorized', + timeoutMs: 1 + })) as { messageId: string; timedOut: boolean } + expect(accepted.messageId).toMatch(/^msg_/) + expect(accepted.timedOut).toBe(true) }) it('returns timedOut when no reply arrives in the window', async () => { setup() - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + createAskingDispatch() vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') const result = (await call('orchestration.ask', { from: 'term_worker', - to: 'term_coord', question: 'still there?', timeoutMs: 1 })) as { answer: string | null; timedOut: boolean; messageId: string | null } expect(result.timedOut).toBe(true) expect(result.answer).toBeNull() - expect(result.messageId).toBeNull() - // Outbound message still persisted (coordinator can still see it). - const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + expect(result.messageId).toMatch(/^msg_/) + const outbound = db.getInbox(10).find((message) => message.type === 'question') expect(outbound).toBeTruthy() + expect(db.getQuestion(outbound!.id)?.status).toBe('pending') + }) + + it('resumes the original question without creating a duplicate', async () => { + setup() + const { dispatch } = createAskingDispatch() + const created = db.createQuestion({ + runId: activeRunId!, + dispatchId: dispatch.id, + askerHandle: 'term_worker', + question: 'Resume me' + }) + db.answerQuestion({ + messageId: created.message.id, + runId: activeRunId!, + consumerGeneration: db.getRun(activeRunId!)!.consumer_generation, + body: 'recorded answer' + }) + const messageCount = db.getInbox(100).length + + const result = (await call('orchestration.ask', { + from: 'term_worker', + resume: created.message.id, + timeoutMs: 500 + })) as { answer: string; messageId: string; timedOut: boolean } + + expect(result).toMatchObject({ + answer: 'recorded answer', + messageId: created.message.id, + timedOut: false + }) + expect(db.getInbox(100)).toHaveLength(messageCount) }) it('returns promptly when the RPC signal aborts while waiting', async () => { setup() + createAskingDispatch() vi.useFakeTimers() const controller = new AbortController() const method = findMethod('orchestration.ask') const parsed = method.params!.parse({ from: 'term_worker', - to: 'term_coord', question: 'still there?', timeoutMs: 60_000 }) @@ -1725,17 +2619,17 @@ describe('orchestration RPC methods', () => { const promise = method.handler(parsed, { runtime, signal: controller.signal - }) as Promise<{ timedOut: boolean }> + }) as Promise<{ timedOut: boolean; cancelled: boolean }> controller.abort() const outcomePromise = Promise.race([ - promise.then((result) => (result.timedOut ? 'aborted' : 'answered')), + promise.then((result) => (result.cancelled ? 'cancelled' : 'answered')), new Promise<'pending'>((resolve) => setTimeout(() => resolve('pending'), 0)) ]) await vi.advanceTimersByTimeAsync(0) const outcome = await outcomePromise - expect(outcome).toBe('aborted') + expect(outcome).toBe('cancelled') } finally { vi.useRealTimers() } @@ -1753,38 +2647,35 @@ describe('orchestration RPC methods', () => { expect(db.getInbox(10)).toHaveLength(0) }) - it('does not return distractor messages on a different thread', async () => { + it('ignores unrelated wakes until the durable question is answered', async () => { setup() - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + createAskingDispatch() vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) let wakeCount = 0 vi.spyOn(runtime, 'waitForMessage').mockImplementation(async () => { wakeCount++ - const outbound = db.getInbox(20).find((m) => m.type === 'decision_gate') + const outbound = db.getInbox(20).find((message) => message.type === 'question') if (wakeCount === 1 && outbound) { - // First wake: distractor in a DIFFERENT thread — must be ignored. db.insertMessage({ - from: 'term_coord', - to: 'term_worker', + from: 'unrelated', + to: `dispatch:${db.getQuestion(outbound.id)!.dispatch_id}`, subject: 'unrelated', body: 'other', - threadId: 'thread_other' + runId: activeRunId }) } else if (wakeCount === 2 && outbound) { - // Second wake: correct thread reply. - db.insertMessage({ - from: 'term_coord', - to: 'term_worker', - subject: 'Re: Question', - body: 'correct answer', - threadId: outbound.id + db.answerQuestion({ + messageId: outbound.id, + runId: activeRunId!, + consumerGeneration: db.getRun(activeRunId!)!.consumer_generation, + body: 'correct answer' }) } + return 'notified' }) const result = (await call('orchestration.ask', { from: 'term_worker', - to: 'term_coord', question: 'filter?', timeoutMs: 2_000 })) as { answer: string; timedOut: boolean } @@ -1799,20 +2690,22 @@ describe('orchestration RPC methods', () => { [Number.MAX_SAFE_INTEGER, ORCHESTRATION_ASK_MAX_TIMEOUT_MS] ])('applies effective timeout %s at the RPC handler boundary', async (requested, expected) => { setup() - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + createAskingDispatch() vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) let observedTimeoutMs: number | undefined vi.spyOn(runtime, 'waitForMessage').mockImplementation(async (_handle, options) => { observedTimeoutMs = options?.timeoutMs - const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + // End the wait loop so the assertion runs against the first budget slice. + const outbound = db.getInbox(10).find((message) => message.type === 'question') + // Why: without a reply the handler's while(true) spins on this mock until vitest times out, hanging instead of failing. expect(outbound).toBeDefined() - db.insertMessage({ - from: 'term_coord', - to: 'term_worker', - subject: 'Re: Question', - body: 'ok', - threadId: outbound!.id + db.answerQuestion({ + messageId: outbound!.id, + runId: activeRunId!, + consumerGeneration: db.getRun(activeRunId!)!.consumer_generation, + body: 'ok' }) + return 'notified' }) const result = (await call('orchestration.ask', { @@ -1829,6 +2722,7 @@ describe('orchestration RPC methods', () => { it('returns a zero effective timeout without entering the waiter', async () => { setup() + createAskingDispatch() const waitForMessage = vi.spyOn(runtime, 'waitForMessage') const result = (await call('orchestration.ask', { @@ -1844,19 +2738,18 @@ describe('orchestration RPC methods', () => { it('parses options CSV with whitespace and empty entries', async () => { setup() - vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + createAskingDispatch('w') vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - vi.spyOn(runtime, 'waitForMessage').mockResolvedValue() + vi.spyOn(runtime, 'waitForMessage').mockResolvedValue('timed_out') await call('orchestration.ask', { from: 'w', - to: 'c', question: 'q', options: 'a, b ,,c', timeoutMs: 1 }) - const outbound = db.getInbox(10).find((m) => m.type === 'decision_gate') + const outbound = db.getInbox(10).find((message) => message.type === 'question') const payload = JSON.parse(outbound!.payload ?? '{}') expect(payload.options).toEqual(['a', 'b', 'c']) }) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index ddf86825e6f..128520f4c33 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -3,24 +3,23 @@ import { z } from 'zod' import { defineMethod, type RpcMethod } from '../core' import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas' import type { MessageType, MessagePriority, TaskStatus } from '../../orchestration/db' +import { MESSAGE_TYPES } from '../../orchestration/types' import { buildDispatchPreamble } from '../../orchestration/preamble' import { formatMessageBanner } from '../../orchestration/formatter' import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups' import { reconcileLifecycleMessage } from '../../orchestration/lifecycle-reconciliation' import { abbreviateOrchestrationTasks } from '../../../../shared/orchestration-task-summary' +import { orchestrationSkillRecoveryData } from '../../../../shared/orchestration-rpc-contract' import { clampOrchestrationAskTimeoutMs } from '../../../../shared/orchestration-ask-timeout' import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates' - -const MESSAGE_TYPES: MessageType[] = [ - 'status', - 'dispatch', - 'worker_done', - 'merge_ready', - 'escalation', - 'handoff', - 'decision_gate', - 'heartbeat' -] +import { ORCHESTRATION_RUN_METHODS } from './orchestration-runs' +import { ORCHESTRATION_WORKER_METHODS } from './orchestration-worker-methods' +import { ORCHESTRATION_FEDERATION_METHODS } from './orchestration-federation-methods' +import { OrchestrationError } from '../../orchestration/orchestration-error' +import type { OrcaRuntimeService } from '../../orca-runtime' +import type { RunRow } from '../../orchestration/types' +import { encodeFederatedControlMessage } from '../../orchestration/federation-control-message' +import { ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION } from '../../../../shared/protocol-version' const TASK_STATUSES: TaskStatus[] = [ 'pending', @@ -32,12 +31,26 @@ const TASK_STATUSES: TaskStatus[] = [ ] function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string { - return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.` + return `${type} messages belong to one exact Dispatch and cannot target a group address.` +} + +function parseRemoteWorkerPayload(payload: string | undefined): Record { + if (!payload) { + return {} + } + try { + const parsed: unknown = JSON.parse(payload) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + throw new OrchestrationError('invalid_argument', 'Message payload must be valid JSON.') + } } const SendParams = z .object({ - to: requiredString('Missing --to'), + to: OptionalString, subject: requiredString('Missing --subject'), from: OptionalString, body: OptionalString, @@ -50,6 +63,7 @@ const SendParams = z 'escalation', 'handoff', 'decision_gate', + 'question', 'heartbeat' ]) .optional(), @@ -58,11 +72,13 @@ const SendParams = z payload: OptionalString, // Why: pane key is the remint-stable identity used to verify worker_done/heartbeat ownership; the from handle stays routing metadata. senderPaneKey: OptionalString, + run: OptionalString, devMode: OptionalBoolean }) .superRefine((params, ctx) => { if ( (params.type !== 'worker_done' && params.type !== 'heartbeat') || + !params.to || !isGroupAddress(params.to) ) { return @@ -83,7 +99,11 @@ const CheckParams = z // Why: `all` surfaces every message and skips mark-read; legacy encoding was the `{unread: false}` trick (design doc §3.2/§3.3). all: OptionalBoolean, types: OptionalString, + format: OptionalBoolean, + // Why: one-release RPC compatibility only; the public CLI uses --format because no terminal input is injected. inject: OptionalBoolean, + ack: OptionalString, + run: OptionalString, wait: OptionalBoolean, timeoutMs: OptionalFiniteNumber }) @@ -105,7 +125,8 @@ const CheckParams = z const ReplyParams = z.object({ id: requiredString('Missing --id'), body: requiredString('Missing --body'), - from: OptionalString + from: OptionalString, + run: OptionalString }) const InboxParams = z.object({ @@ -120,14 +141,17 @@ const TaskCreateParams = z.object({ displayName: OptionalString, deps: OptionalString, parent: OptionalString, - callerTerminalHandle: OptionalString + callerTerminalHandle: OptionalString, + run: OptionalString }) const TaskListParams = z.object({ status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(), ready: OptionalBoolean, // Why: server-side truncation keeps --brief cheap over SSH/relay instead of shipping full specs the CLI throws away. - brief: OptionalBoolean + brief: OptionalBoolean, + run: OptionalString, + callerTerminalHandle: OptionalString }) const TaskUpdateParams = z.object({ @@ -145,7 +169,9 @@ const TaskUpdateParams = z.object({ message: 'Missing --status' }) ), - result: OptionalString + result: OptionalString, + run: OptionalString, + callerTerminalHandle: OptionalString }) const DispatchParams = z.object({ @@ -156,7 +182,8 @@ const DispatchParams = z.object({ inject: OptionalBoolean, dryRun: OptionalBoolean, returnPreamble: OptionalBoolean, - devMode: OptionalBoolean + devMode: OptionalBoolean, + run: OptionalString }) const DispatchShowParams = z.object({ @@ -166,13 +193,24 @@ const DispatchShowParams = z.object({ devMode: OptionalBoolean }) -const AskParams = z.object({ - to: requiredString('Missing --to'), - question: requiredString('Missing --question'), - options: OptionalString, - timeoutMs: OptionalFiniteNumber, - from: OptionalString -}) +const AskParams = z + .object({ + to: OptionalString, + question: OptionalString, + resume: OptionalString, + options: OptionalString, + timeoutMs: OptionalFiniteNumber, + from: OptionalString, + run: OptionalString + }) + .superRefine((params, ctx) => { + if ((params.question ? 1 : 0) + (params.resume ? 1 : 0) !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Choose exactly one of --question or --resume.' + }) + } + }) const ResetParams = z .object({ @@ -192,29 +230,351 @@ const ResetParams = z } }) +function resolveRunScope( + runtime: OrcaRuntimeService, + params: { + runId?: string + callerTerminalHandle?: string + requireCurrentConsumer: boolean + } +): RunRow { + const db = runtime.getOrchestrationDb() + const explicit = params.runId ? db.getRun(params.runId) : undefined + if (params.runId && (!explicit || explicit.legacy === 1)) { + throw new OrchestrationError('run_not_found', `Run ${params.runId} was not found.`) + } + + if (!params.requireCurrentConsumer && explicit) { + return explicit + } + if (!params.callerTerminalHandle) { + throw new OrchestrationError( + 'run_required', + 'No Run is bound. Use orchestration run-create or run-use first. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } + const paneKey = runtime.getTerminalPaneKey(params.callerTerminalHandle) + if (!paneKey) { + throw new OrchestrationError( + 'stable_pane_required', + 'The coordinator terminal has no stable pane identity.' + ) + } + const current = db.getCurrentRunForPane(paneKey) + if (!current) { + if (explicit) { + throw new OrchestrationError( + 'consumer_fenced', + `This coordinator terminal is no longer bound to Run ${explicit.id}.` + ) + } + throw new OrchestrationError( + 'run_required', + 'No Run is bound. Use orchestration run-create or run-use first. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } + if (explicit && current.id !== explicit.id) { + throw new OrchestrationError( + 'consumer_fenced', + `This coordinator terminal is bound to ${current.id}, not ${explicit.id}.` + ) + } + return current +} + +function parseMessageTypes(rawTypes: string | undefined): MessageType[] | undefined { + const types = rawTypes + ?.split(',') + .map((type) => type.trim()) + .filter(Boolean) as MessageType[] | undefined + const invalidTypes = types?.filter((type) => !MESSAGE_TYPES.includes(type)) + if (invalidTypes && invalidTypes.length > 0) { + throw new OrchestrationError('invalid_argument', `Invalid --types: ${invalidTypes.join(',')}`) + } + return types && types.length > 0 ? types : undefined +} + +function resolveMessageRun( + runtime: OrcaRuntimeService, + params: { + from?: string + senderPaneKey?: string + to?: string + runId?: string + payload?: string + } +): { run: RunRow | undefined; dispatchId: string | undefined } { + const db = runtime.getOrchestrationDb() + let dispatchId: string | undefined + if (params.payload) { + try { + const payload: unknown = JSON.parse(params.payload) + if ( + payload && + typeof payload === 'object' && + !Array.isArray(payload) && + typeof (payload as { dispatchId?: unknown }).dispatchId === 'string' + ) { + dispatchId = (payload as { dispatchId: string }).dispatchId + } + } catch { + // Lifecycle validation owns malformed payload errors; routing simply cannot derive a Dispatch. + } + } + if (!dispatchId && params.to?.startsWith('dispatch:')) { + dispatchId = params.to.slice('dispatch:'.length) + } + + const dispatch = dispatchId + ? db.getDispatchContextById(dispatchId) + : params.from + ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) + : undefined + if (params.to?.startsWith('dispatch:') && !dispatch) { + throw new OrchestrationError( + 'dispatch_not_found', + `Dispatch ${dispatchId ?? ''} was not found.` + ) + } + const targetRunId = params.to?.startsWith('run:') ? params.to.slice('run:'.length) : undefined + const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id + let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined + + if (!run && params.from) { + const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) + run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + } + if (resolvedRunId && (!run || run.legacy === 1)) { + throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) + } + if (run && targetRunId && targetRunId !== run.id) { + throw new OrchestrationError('run_not_found', `Run ${targetRunId} was not found.`) + } + if (run && dispatch && dispatch.run_id !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${dispatch.id} belongs to Run ${dispatch.run_id}, not ${run.id}.` + ) + } + return { run, dispatchId: dispatch?.id ?? dispatchId } +} + +function rejectFederatedExplicitTarget(params: { to?: string; run?: string }): void { + if (params.to || params.run) { + throw new OrchestrationError( + 'invalid_argument', + 'Federated Dispatch messages route to their Run home; omit --to and --run.' + ) + } +} + export const ORCHESTRATION_METHODS: RpcMethod[] = [ + ...ORCHESTRATION_RUN_METHODS, + ...ORCHESTRATION_WORKER_METHODS, + ...ORCHESTRATION_FEDERATION_METHODS, defineMethod({ name: 'orchestration.send', params: SendParams, - handler: async (params, { runtime }) => { + handler: async (params, { runtime, orchestrationCapability }) => { const db = runtime.getOrchestrationDb() const from = params.from ?? 'unknown' - // Why: older shells may lack ORCA_PANE_KEY, but the runtime still knows the pane behind their handle; persist that authority. - const senderPaneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + // Why: caller-supplied pane fields are only compatibility metadata; lifecycle authority uses the runtime-observed pane plus capability. + const senderPaneKey = runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = senderPaneKey + ? db.findActiveRemoteAttachmentForPane(senderPaneKey) + : undefined + if (remoteAttachment) { + rejectFederatedExplicitTarget(params) + const processIncarnation = runtime.getTerminalProcessIncarnation(from) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: remoteAttachment.dispatch_id, + capability: orchestrationCapability, + paneKey: senderPaneKey ?? null, + processIncarnation + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + const type = (params.type ?? 'status') as MessageType + const payload = parseRemoteWorkerPayload(params.payload) + if ( + typeof payload.dispatchId === 'string' && + payload.dispatchId !== remoteAttachment.dispatch_id + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${payload.dispatchId} is not the active remote Dispatch for this pane.` + ) + } + const outcome = + type === 'worker_done' && + (payload.outcome === 'succeeded' || payload.outcome === 'failed') + ? payload.outcome + : undefined + if (type === 'worker_done' && !outcome) { + throw new OrchestrationError( + 'invalid_argument', + 'Remote worker_done requires outcome=succeeded|failed.' + ) + } + const relay = db.enqueueFederationRelay({ + dispatchId: remoteAttachment.dispatch_id, + direction: 'to_home', + kind: type, + payload: JSON.stringify({ + from, + subject: params.subject, + body: params.body ?? '', + type, + priority: params.priority ?? 'normal', + threadId: params.threadId ?? null, + payload: params.payload ?? null + }), + settleRemoteOutcome: outcome + }) + return { + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'run_home', + accepted: true + }, + ...(outcome + ? { lifecycle: { action: outcome === 'succeeded' ? 'completed' : 'failed' } } + : {}) + } + } + const routing = resolveMessageRun(runtime, { + from, + senderPaneKey, + to: params.to, + runId: params.run, + payload: params.payload + }) + if (params.to?.startsWith('task:')) { + throw new OrchestrationError( + 'invalid_argument', + 'Task recipients are intentionally unsupported; use run: or dispatch:.' + ) + } + let to = params.to + if ( + routing.run && + (!to || + ((params.type === 'worker_done' || params.type === 'heartbeat') && routing.dispatchId)) + ) { + to = `run:${routing.run.id}` + } + if (!to) { + throw new OrchestrationError( + 'run_required', + 'No recipient or active Dispatch Run could be resolved. No effects were applied.', + orchestrationSkillRecoveryData() + ) + } - if (!isGroupAddress(params.to)) { + if (!isGroupAddress(to)) { + const federatedDispatchId = routing.dispatchId + const federatedTarget = + federatedDispatchId && to === `dispatch:${federatedDispatchId}` + ? db.getFederatedDispatch(federatedDispatchId) + : undefined + if (federatedTarget && federatedDispatchId) { + const dispatchId = federatedDispatchId + if ( + federatedTarget.protocol_version < + ORCHESTRATION_FEDERATION_CONTROL_MAIL_PROTOCOL_VERSION + ) { + throw new OrchestrationError( + 'capability_unsupported', + `Federated Dispatch ${dispatchId} does not support coordinator control mail; start a fresh worker after updating its Orca server.` + ) + } + if (db.getWorkerDispatch(dispatchId)?.state !== 'ready') { + throw new OrchestrationError( + 'dispatch_inactive', + `Federated Dispatch ${dispatchId} is not active.` + ) + } + if (params.type === 'worker_done' || params.type === 'heartbeat') { + throw new OrchestrationError( + 'invalid_argument', + 'Coordinator-to-worker control mail cannot report worker lifecycle.' + ) + } + const relay = db.enqueueFederationRelay({ + dispatchId, + direction: 'to_worker', + kind: 'control_message', + payload: encodeFederatedControlMessage({ + from, + subject: params.subject, + body: params.body ?? '', + type: (params.type ?? 'status') as MessageType, + priority: (params.priority ?? 'normal') as MessagePriority, + threadId: params.threadId ?? null, + payload: params.payload ?? null + }) + }) + runtime.ensureOrchestrationFederationRelay(routing.run?.id) + return { + relay: { + messageId: relay.message_id, + sequence: relay.sequence, + dispatchId: relay.dispatch_id, + destination: 'worker', + accepted: true + } + } + } // Point-to-point — existing single-recipient behavior const msg = db.insertMessage({ from, - to: params.to, + to, subject: params.subject, body: params.body, type: params.type as MessageType, priority: params.priority as MessagePriority, threadId: params.threadId, payload: params.payload, - senderPaneKey + senderPaneKey, + runId: routing.run?.id }) + const dispatch = routing.dispatchId + ? db.getDispatchContextById(routing.dispatchId) + : undefined + if ((msg.type === 'worker_done' || msg.type === 'heartbeat') && dispatch?.capability_hash) { + const authority = db.verifyDispatchCapability({ + dispatchId: dispatch.id, + capability: orchestrationCapability, + paneKey: senderPaneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined + }) + if (!authority.valid) { + const rejection = + db.convertLifecycleMessageToRejection( + msg.id, + 'dispatch_capability_invalid', + authority.reason + ) ?? msg + runtime.notifyMessageArrived(to, rejection.type) + return { + message: rejection, + lifecycle: { + action: 'rejected', + code: 'dispatch_capability_invalid', + reason: authority.reason + } + } + } + } // Why: reconcile releases the dispatch lock before waking recipients, else a woken coordinator re-dispatches while the lock is still held. if (msg.type === 'worker_done' || msg.type === 'heartbeat') { const reconciled = reconcileLifecycleMessage(db, msg) @@ -224,24 +584,22 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } if (reconciled.action === 'rejected') { const rejection = db.getMessageById(msg.id) ?? msg - runtime.deliverPendingMessagesForHandle(params.to) - runtime.notifyMessageArrived(params.to, rejection.type) + runtime.notifyMessageArrived(to, rejection.type) return { message: rejection, lifecycle: reconciled } } } - runtime.deliverPendingMessagesForHandle(params.to) - runtime.notifyMessageArrived(params.to, msg.type) + runtime.notifyMessageArrived(to, msg.type) return { message: msg } } // Why: fan out one message per recipient (independent read-tracking) but share a thread_id for correlation (Section 4.5). const { terminals } = await runtime.listTerminals() - const handles = resolveGroupAddress(params.to, from, terminals, (handle: string) => + const handles = resolveGroupAddress(to, from, terminals, (handle: string) => runtime.getAgentStatusForHandle(handle) ) if (handles.length === 0) { - throw new Error(`No recipients resolved for group address: ${params.to}`) + throw new Error(`No recipients resolved for group address: ${to}`) } const threadId = params.threadId ?? `thread_${Date.now()}` @@ -255,11 +613,11 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ priority: params.priority as MessagePriority, threadId, payload: params.payload, - senderPaneKey + senderPaneKey, + runId: routing.run?.id }) ) for (const message of messages) { - runtime.deliverPendingMessagesForHandle(message.to_handle) runtime.notifyMessageArrived(message.to_handle, message.type) } @@ -273,15 +631,213 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ handler: async (params, { runtime, signal }) => { const db = runtime.getOrchestrationDb() const handle = params.terminal ?? 'unknown' - const typeFilter = params.types - ? (params.types - .split(',') - .map((t) => t.trim()) - .filter(Boolean) as MessageType[]) - : undefined - const invalidTypes = typeFilter?.filter((t) => !MESSAGE_TYPES.includes(t)) - if (invalidTypes && invalidTypes.length > 0) { - throw new Error(`Invalid --types: ${invalidTypes.join(',')}`) + const typeFilter = parseMessageTypes(params.types) + + const paneKey = runtime.getTerminalPaneKey(handle) + const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (params.run || boundRun) { + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: handle, + requireCurrentConsumer: true + }) + const generation = run.consumer_generation + const address = `run:${run.id}` + runtime.ensureOrchestrationFederationRelay(run.id) + + const acknowledged = params.ack + ? db.acknowledgeRunDelivery({ + runId: run.id, + consumerGeneration: generation, + deliveryId: params.ack + }) + : undefined + if (params.peek || params.all || params.unread === false) { + const history = db.getRunMailboxHistory(run.id, 100, typeFilter) + const messages = + params.all || (params.unread === false && !params.peek) + ? history + : history.filter((message) => message.read === 0) + const result = { + messages, + count: messages.length, + acknowledged: acknowledged?.delivery.id ?? null + } + if (params.format || params.inject) { + return { + ...result, + formatted: messages.map(formatMessageBanner).join('\n\n'), + runId: run.id + } + } + return { ...result, runId: run.id } + } + + const readDelivery = (wakeTypes?: MessageType[]) => + db.getOrCreateRunDelivery({ + runId: run.id, + consumerGeneration: generation, + wakeTypes + }) + let current = readDelivery(params.wait ? typeFilter : undefined) + if (current) { + return { + runId: run.id, + deliveryId: current.delivery.id, + messages: current.messages, + count: current.messages.length, + replayed: current.replayed, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format || params.inject + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + if (!params.wait) { + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false + } + } + + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal, + exclusive: true + }) + const latestRun = db.getRun(run.id) + if (!latestRun || latestRun.consumer_generation !== generation) { + throw new OrchestrationError( + 'consumer_fenced', + 'This mailbox consumer was replaced while waiting.' + ) + } + if (waitResult === 'waiter_exists') { + throw new OrchestrationError( + 'waiter_exists', + `Run ${run.id} already has an active actionable waiter.` + ) + } + if (waitResult === 'timed_out') { + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: true, + cancelled: false, + connectionLost: false + } + } + if (waitResult === 'cancelled') { + return { + runId: run.id, + deliveryId: null, + messages: [], + count: 0, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: true, + connectionLost: signal?.aborted === true + } + } + + current = readDelivery(typeFilter) + return { + runId: run.id, + deliveryId: current?.delivery.id ?? null, + messages: current?.messages ?? [], + count: current?.messages.length ?? 0, + replayed: current?.replayed ?? false, + acknowledged: acknowledged?.delivery.id ?? null, + timedOut: false, + cancelled: false, + connectionLost: false, + ...(params.format && current + ? { formatted: current.messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + + const activeDispatch = db.getActiveDispatchForIdentity(handle, paneKey ?? undefined) + const remoteAttachment = + !activeDispatch && paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if ( + remoteAttachment && + !db.isRemoteAttachmentProcessCurrent({ + dispatchId: remoteAttachment.dispatch_id, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(handle) + }) + ) { + throw new OrchestrationError( + 'dispatch_inactive', + `Dispatch ${remoteAttachment.dispatch_id} is no longer attached to this worker process.` + ) + } + const workerMailbox = activeDispatch + ? { dispatchId: activeDispatch.id, runId: activeDispatch.run_id } + : remoteAttachment + ? { dispatchId: remoteAttachment.dispatch_id, runId: undefined } + : undefined + if (workerMailbox) { + const address = `dispatch:${workerMailbox.dispatchId}` + const showAll = params.all === true || (params.unread === false && params.peek !== true) + const messages = showAll + ? db.getAllMessagesForHandle(address, 100, typeFilter) + : db.getUnreadMessages(address, typeFilter) + if (!showAll && params.peek !== true && messages.length > 0) { + db.markAsRead(messages.map((message) => message.id)) + } + if (messages.length > 0 || !params.wait) { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages, + count: messages.length, + ...(params.format || params.inject + ? { formatted: messages.map(formatMessageBanner).join('\n\n') } + : {}) + } + } + const waitResult = await runtime.waitForMessage(address, { + typeFilter: typeFilter as string[] | undefined, + timeoutMs: params.timeoutMs ?? undefined, + signal + }) + if (waitResult === 'timed_out' || waitResult === 'cancelled') { + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: [], + count: 0, + timedOut: waitResult === 'timed_out', + cancelled: waitResult === 'cancelled', + connectionLost: waitResult === 'cancelled' && signal?.aborted === true + } + } + const arrived = db.getUnreadMessages(address, typeFilter) + db.markAsRead(arrived.map((message) => message.id)) + return { + ...(workerMailbox.runId ? { runId: workerMailbox.runId } : {}), + dispatchId: workerMailbox.dispatchId, + messages: arrived, + count: arrived.length, + ...(params.format || params.inject + ? { formatted: arrived.map(formatMessageBanner).join('\n\n') } + : {}) + } } // Why: unread:false is honored for one release as a compat shim so in-flight callers don't break (design doc §5). @@ -305,7 +861,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ db.markAsRead(messages.map((m) => m.id)) } - if (params.inject) { + if (params.format || params.inject) { const formatted = visibleMessages.map(formatMessageBanner).join('\n\n') return { messages: visibleMessages, formatted, count: visibleMessages.length } } @@ -337,13 +893,49 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.reply', params: ReplyParams, - handler: (params, { runtime }) => { + handler: async (params, { runtime }) => { const db = runtime.getOrchestrationDb() const original = db.getMessageById(params.id) if (!original) { throw new Error(`Message not found: ${params.id}`) } + const question = db.getQuestion(params.id) + if (question) { + const run = resolveRunScope(runtime, { + runId: params.run ?? question.run_id, + callerTerminalHandle: params.from, + requireCurrentConsumer: true + }) + const answered = db.answerQuestion({ + messageId: question.message_id, + runId: run.id, + consumerGeneration: run.consumer_generation, + body: params.body + }) + const federated = db.getFederatedDispatch(question.dispatch_id) + if (federated) { + db.enqueueFederationRelay({ + dispatchId: question.dispatch_id, + direction: 'to_worker', + kind: 'reply', + payload: JSON.stringify({ + questionId: question.message_id, + answerMessageId: answered.message.id, + body: params.body + }) + }) + runtime.ensureOrchestrationFederationRelay(run.id) + } else { + runtime.notifyMessageArrived(`dispatch:${question.dispatch_id}`, 'status') + } + return { + message: answered.message, + question: answered.question, + duplicate: answered.duplicate + } + } + db.markAsRead([original.id]) const reply = db.insertMessage({ @@ -395,7 +987,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ displayName: params.displayName, deps, parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle + createdByTerminalHandle: params.callerTerminalHandle, + runId: resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true + }).id }) return { task } } @@ -406,10 +1003,20 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params: TaskListParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() + const explicitRun = params.run ? db.getRun(params.run) : undefined + const run = + explicitRun?.legacy === 1 + ? explicitRun + : resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: params.run === undefined + }) // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. const joined = db.listTasksWithDispatch({ status: params.status as TaskStatus, - ready: params.ready + ready: params.ready, + runId: run.id }) const tasks = joined.map((row) => { const { assignee_handle, dispatch_id, ...base } = row @@ -419,6 +1026,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ return base }) return { + runId: run.id, + legacyReadOnly: run.legacy === 1, tasks: params.brief ? abbreviateOrchestrationTasks(tasks) : tasks, count: tasks.length } @@ -430,6 +1039,18 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ params: TaskUpdateParams, handler: (params, { runtime }) => { const db = runtime.getOrchestrationDb() + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.callerTerminalHandle, + requireCurrentConsumer: true + }) + const existing = db.getTask(params.id) + if (!existing || existing.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${params.id} was not found in Run ${run.id}.` + ) + } const task = db.updateTaskStatus(params.id, params.status, params.result) if (!task) { throw new Error(`Task not found: ${params.id}`) @@ -447,6 +1068,17 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!task) { throw new Error(`Task not found: ${params.task}`) } + const run = resolveRunScope(runtime, { + runId: params.run, + callerTerminalHandle: params.from, + requireCurrentConsumer: true + }) + if (task.run_id !== run.id) { + throw new OrchestrationError( + 'task_not_found', + `Task ${task.id} was not found in Run ${run.id}.` + ) + } // Why: dry-run previews the preamble without mutating state, so it skips the ready-status check and uses a placeholder dispatchId. if (params.dryRun) { @@ -485,11 +1117,23 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } } - const ctx = db.createDispatchContext( - params.task, - to, - runtime.getTerminalPaneKey(to) ?? undefined - ) + const assigneePaneKey = runtime.getTerminalPaneKey(to) ?? undefined + const processIncarnation = runtime.getTerminalProcessIncarnation(to) ?? undefined + if (params.inject && (!assigneePaneKey || !processIncarnation)) { + throw new OrchestrationError( + 'stable_pane_required', + `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` + ) + } + + const ctx = db.createDispatchContext(params.task, to, assigneePaneKey) + const dispatchCapability = params.inject + ? db.mintDispatchCapability({ + dispatchId: ctx.id, + paneKey: assigneePaneKey as string, + processIncarnation: processIncarnation as string + }) + : undefined // Why: built after ctx so dispatchId is the real ctx.id, letting heartbeats attribute liveness to a specific dispatch context, not just a task. const preamble = buildDispatchPreamble({ @@ -498,6 +1142,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ taskSpec: task.spec, coordinatorHandle: params.from ?? 'coordinator', workerHandle: to, + dispatchCapability, devMode: params.devMode, cliCommand: runtime.getTerminalOrchestrationCliCommand(to) }) @@ -558,11 +1203,14 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ defineMethod({ name: 'orchestration.ask', params: AskParams, - handler: async (params, { runtime, signal }) => { - // Why: group addresses have no unambiguous answer semantics; rejecting avoids a silent timeout on a decision_gate no one subscribes to. - if (isGroupAddress(params.to)) { + handler: async ( + params, + { runtime, signal, orchestrationCapability, recordMutationReceipt } + ) => { + // Why: group addresses have no unambiguous first-answer authority. + if (params.to && isGroupAddress(params.to)) { throw new Error( - 'ask does not support group addresses; use send --type decision_gate for fan-out questions' + 'ask does not support group addresses; use send for non-blocking fan-out questions' ) } @@ -570,51 +1218,143 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ const from = params.from ?? 'unknown' // Why: echoed on every return so a clamped caller reports the budget actually waited, not the one it asked for. const timeoutMs = clampOrchestrationAskTimeoutMs(params.timeoutMs) + const paneKey = runtime.getTerminalPaneKey(from) ?? undefined + const remoteAttachment = paneKey ? db.findActiveRemoteAttachmentForPane(paneKey) : undefined + if (remoteAttachment) { + rejectFederatedExplicitTarget(params) + return askRemoteRunHome({ + params: { ...params, timeoutMs }, + runtime, + signal, + orchestrationCapability, + recordMutationReceipt, + from, + paneKey: paneKey as string, + dispatchId: remoteAttachment.dispatch_id, + taskId: remoteAttachment.task_id + }) + } + const activeDispatch = db.getActiveDispatchForIdentity(from, paneKey) + if (!activeDispatch) { + throw new OrchestrationError( + 'dispatch_inactive', + 'ask requires an active supervised Dispatch.' + ) + } + if (activeDispatch.capability_hash) { + const authority = db.verifyDispatchCapability({ + dispatchId: activeDispatch.id, + capability: orchestrationCapability, + paneKey, + processIncarnation: runtime.getTerminalProcessIncarnation(from) ?? undefined + }) + if (!authority.valid) { + throw new OrchestrationError('dispatch_capability_invalid', authority.reason) + } + } const options = params.options ?.split(',') .map((s) => s.trim()) .filter(Boolean) ?? [] + let question = params.resume ? db.getQuestion(params.resume) : undefined + if (params.resume) { + if (!question || question.dispatch_id !== activeDispatch.id) { + throw new OrchestrationError( + 'question_not_found', + `Question ${params.resume} does not belong to this active Dispatch.` + ) + } + } else { + const run = db.getRun(activeDispatch.run_id) + if (!run || run.legacy === 1) { + throw new OrchestrationError( + 'run_not_found', + `Run ${activeDispatch.run_id} was not found.` + ) + } + if (params.run && params.run !== run.id) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` + ) + } + if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + throw new OrchestrationError( + 'dispatch_run_mismatch', + `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` + ) + } + const created = db.createQuestion({ + runId: run.id, + dispatchId: activeDispatch.id, + askerHandle: from, + question: params.question as string, + options + }) + question = created.question + runtime.notifyMessageArrived(`run:${run.id}`, created.message.type) + } - const payload = JSON.stringify({ question: params.question, options }) - const outbound = db.insertMessage({ - from, - to: params.to, - subject: 'Question', - body: params.question, - type: 'decision_gate', - payload + const questionId = question.message_id + recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs }) - runtime.deliverPendingMessagesForHandle(params.to) - runtime.notifyMessageArrived(params.to, outbound.type) - - const threadId = outbound.id const deadline = Date.now() + timeoutMs - const afterSequence = outbound.sequence - - // Why: waitForMessage is handle-scoped, so re-query by thread each wake and bound by remaining budget so distractor messages can't loop forever. while (true) { - const replies = db.getThreadMessagesFor(threadId, from, afterSequence) - if (replies.length > 0) { - const reply = replies[0] - db.markAsRead([reply.id]) + const current = db.getQuestion(questionId) + if (!current || current.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its Dispatch is inactive.` + ) + } + if (current.status === 'answered') { return { - answer: reply.body, - messageId: reply.id, - threadId, + answer: current.answer_body, + messageId: questionId, + answerMessageId: current.answer_message_id, + threadId: questionId, timedOut: false, + cancelled: false, + connectionLost: false, timeoutMs } } if (signal?.aborted) { - return { answer: null, messageId: null, threadId, timedOut: true, timeoutMs } + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } } const remainingMs = deadline - Date.now() if (remainingMs <= 0) { - return { answer: null, messageId: null, threadId, timedOut: true, timeoutMs } + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } } - // Why: signal releases the waiter on client disconnect while the already-sent decision gate stays visible to the recipient. - await runtime.waitForMessage(from, { timeoutMs: remainingMs, signal }) + await runtime.waitForMessage(`dispatch:${activeDispatch.id}`, { + timeoutMs: remainingMs, + signal + }) } } }), @@ -642,3 +1382,127 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ } }) ] + +async function askRemoteRunHome(args: { + params: z.infer + runtime: OrcaRuntimeService + signal?: AbortSignal + orchestrationCapability?: string + recordMutationReceipt?: (receipt: unknown) => void + from: string + paneKey: string + dispatchId: string + taskId: string +}): Promise { + const db = args.runtime.getOrchestrationDb() + const timeoutMs = clampOrchestrationAskTimeoutMs(args.params.timeoutMs) + if ( + !db.verifyRemoteAttachmentAuthority({ + dispatchId: args.dispatchId, + capability: args.orchestrationCapability, + paneKey: args.paneKey, + processIncarnation: args.runtime.getTerminalProcessIncarnation(args.from) + }) + ) { + throw new OrchestrationError( + 'dispatch_capability_invalid', + 'The remote Dispatch capability or exact worker process is invalid.' + ) + } + const options = + args.params.options + ?.split(',') + .map((option) => option.trim()) + .filter(Boolean) ?? [] + let questionId = args.params.resume + if (questionId) { + const existing = db.getRemoteQuestion(questionId) + if (!existing || existing.dispatch_id !== args.dispatchId) { + throw new OrchestrationError( + 'question_not_found', + `Question ${questionId} does not belong to this remote Dispatch.` + ) + } + } else { + const relay = db.enqueueFederationRelay({ + dispatchId: args.dispatchId, + direction: 'to_home', + kind: 'question', + payload: JSON.stringify({ + from: args.from, + subject: 'Question', + body: args.params.question as string, + type: 'question', + priority: 'normal', + threadId: null, + payload: JSON.stringify({ + taskId: args.taskId, + dispatchId: args.dispatchId, + question: args.params.question, + options + }) + }), + remoteQuestion: true + }) + questionId = relay.message_id + } + args.recordMutationReceipt?.({ + accepted: true, + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + }) + const deadline = Date.now() + timeoutMs + while (true) { + const question = db.getRemoteQuestion(questionId) + if (!question || question.status === 'closed') { + throw new OrchestrationError( + 'dispatch_inactive', + `Question ${questionId} closed because its remote Dispatch is inactive.` + ) + } + if (question.status === 'answered') { + return { + answer: question.answer_body, + messageId: questionId, + answerMessageId: question.answer_message_id, + threadId: questionId, + timedOut: false, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + if (args.signal?.aborted) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: false, + cancelled: true, + connectionLost: true, + timeoutMs + } + } + const remainingMs = deadline - Date.now() + if (remainingMs <= 0) { + return { + answer: null, + messageId: questionId, + threadId: questionId, + timedOut: true, + cancelled: false, + connectionLost: false, + timeoutMs + } + } + await args.runtime.waitForMessage(`dispatch:${args.dispatchId}`, { + timeoutMs: remainingMs, + signal: args.signal + }) + } +} diff --git a/src/main/runtime/rpc/methods/plugins.test.ts b/src/main/runtime/rpc/methods/plugins.test.ts new file mode 100644 index 00000000000..bf67d29f19a --- /dev/null +++ b/src/main/runtime/rpc/methods/plugins.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcContext, RpcMethod } from '../core' +import type { PluginService } from '../../../plugins/plugin-service' +import { PLUGIN_METHODS, setPluginServiceForRpc } from './plugins' + +const SESSION_TOKEN = 's'.repeat(43) + +function method(name: string): RpcMethod { + const found = PLUGIN_METHODS.find((entry) => entry.name === name) + if (!found) { + throw new Error(`missing ${name}`) + } + if ('stream' in found) { + throw new Error(`${name} is streaming`) + } + return found +} + +function context(connectionId?: string): RpcContext { + return { runtime: {} as RpcContext['runtime'], connectionId, clientId: 'paired-device' } +} + +afterEach(() => setPluginServiceForRpc(null)) + +describe('plugin panel serve RPC identity', () => { + it('leaves the raw panel envelope for session resolution and admission', () => { + const schema = method('plugins.panelAction').params! + + expect( + schema.safeParse({ + pluginId: 'orca-samples.other', + unexpected: 'x'.repeat(100_000) + }).success + ).toBe(true) + }) + + it('binds panel loading and actions to the same runtime connection owner', async () => { + const service = { + whenReady: vi.fn().mockResolvedValue(undefined), + panels: { + open: vi.fn().mockResolvedValue({ html: '

    panel

    ', sessionToken: SESSION_TOKEN }), + execute: vi.fn().mockResolvedValue({ ok: true, value: { branch: 'main' } }), + bindOwnerSignal: vi.fn(), + revokeOwner: vi.fn() + } + } as unknown as PluginService + setPluginServiceForRpc(service) + const rpcContext = context('connection-one') + + await expect( + method('plugins.readPanelEntry').handler( + { pluginKey: 'orca-samples.demo', panelId: 'dashboard' }, + rpcContext + ) + ).resolves.toEqual({ html: '

    panel

    ', sessionToken: SESSION_TOKEN }) + expect(service.panels.open).toHaveBeenCalledWith( + 'runtime:connection-one', + 'orca-samples.demo', + 'dashboard' + ) + + await expect( + method('plugins.panelAction').handler( + { sessionToken: SESSION_TOKEN, action: 'workspace.readContext', params: {} }, + rpcContext + ) + ).resolves.toEqual({ outcome: { ok: true, value: { branch: 'main' } } }) + expect(service.panels.execute).toHaveBeenCalledWith('runtime:connection-one', { + sessionToken: SESSION_TOKEN, + action: 'workspace.readContext', + params: {} + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/plugins.ts b/src/main/runtime/rpc/methods/plugins.ts new file mode 100644 index 00000000000..f463feff1cf --- /dev/null +++ b/src/main/runtime/rpc/methods/plugins.ts @@ -0,0 +1,154 @@ +import { z } from 'zod' +import { defineMethod, type RpcContext, type RpcMethod } from '../core' +import type { PluginPanelEntry } from '../../../../shared/plugins/plugin-panel-bridge' +import { listPluginsForClients } from '../../../ipc/plugins' +import type { PluginListEntry } from '../../../plugins/plugin-list-projection' +import type { PluginService } from '../../../plugins/plugin-service' +import { + pluginConsentRequestSchema, + type PluginConsentRequest +} from '../../../../shared/plugins/plugin-consent-request' +import { isQualifiedPluginKey } from '../../../../shared/plugins/plugin-manifest' + +/** + * Serve/headless parity surface: the same consent, enablement, panel-action, + * and command paths the desktop IPC handlers expose, over runtime RPC. Both + * routes execute through PluginService's single chokepoint, so a permission + * decision can never differ between a local window and a paired client. + */ + +// Why: RpcContext only carries the OrcaRuntimeService, and plugins are a +// separate composition-root service — inject via module setter the way the +// desktop entry wires it, instead of widening the shared RPC context type. +let pluginServiceForRpc: PluginService | null = null +// Consent/enablement need the settings Store too, so the entry injects bound +// closures instead of the store itself. +let pluginConsentForRpc: ((request: PluginConsentRequest) => Promise) | null = null +let pluginEnablementForRpc: ((pluginKey: string, enabled: boolean) => Promise) | null = null + +export function setPluginServiceForRpc( + service: PluginService | null, + writes?: { + applyConsent: (request: PluginConsentRequest) => Promise + applyEnablement: (pluginKey: string, enabled: boolean) => Promise + } +): void { + pluginServiceForRpc = service + pluginConsentForRpc = writes?.applyConsent ?? null + pluginEnablementForRpc = writes?.applyEnablement ?? null +} + +function requirePluginService(): PluginService { + if (!pluginServiceForRpc) { + throw new Error('Plugin service is not available on this runtime') + } + return pluginServiceForRpc +} + +const PluginSetEnabledParams = z.object({ + pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'), + enabled: z.boolean() +}) + +const PluginReadPanelEntryParams = z.object({ + pluginKey: z.string().min(1), + panelId: z.string().min(1) +}) + +const PluginInvokeCommandParams = z.object({ + pluginKey: z.string().min(1), + commandId: z.string().min(1), + args: z.unknown().optional() +}) + +async function listForRpc(): Promise { + return listPluginsForClients(requirePluginService()) +} + +function rpcPanelOwner(context: RpcContext): string { + // Why: the bearer session must not cross paired-client connections even + // when two sockets authenticate as the same device. + return `runtime:${context.connectionId ?? context.clientId ?? 'local'}` +} + +function bindRpcPanelOwner(service: PluginService, context: RpcContext): string { + const ownerKey = rpcPanelOwner(context) + service.panels.bindOwnerSignal(ownerKey, context.signal) + return ownerKey +} + +export const PLUGIN_METHODS: readonly RpcMethod[] = [ + defineMethod({ + name: 'plugins.list', + params: null, + handler: async () => listForRpc() + }), + defineMethod({ + // Why: headless serve has no consent dialog — an explicit consent call is + // the only way a pending plugin becomes active on a server. + name: 'plugins.consent', + params: pluginConsentRequestSchema, + handler: async (params) => { + const service = requirePluginService() + await service.whenReady() + if (!pluginConsentForRpc) { + throw new Error('Plugin consent is not available on this runtime') + } + await pluginConsentForRpc(params) + return listForRpc() + } + }), + defineMethod({ + name: 'plugins.setEnabled', + params: PluginSetEnabledParams, + handler: async (params) => { + const service = requirePluginService() + await service.whenReady() + if (!pluginEnablementForRpc) { + throw new Error('Plugin enablement is not available on this runtime') + } + await pluginEnablementForRpc(params.pluginKey, params.enabled) + return listForRpc() + } + }), + defineMethod({ + // Why: headless serve clients relay panel bridge requests over RPC, so + // capability enforcement must live behind this method too, not only in + // the desktop IPC handler. + name: 'plugins.panelAction', + // Why: raw admission must run before strict schema parsing so malformed + // and oversized traffic cannot bypass the panel budget. + params: z.unknown(), + handler: async (params, context) => { + const service = requirePluginService() + await service.whenReady() + return { + outcome: await service.panels.execute(bindRpcPanelOwner(service, context), params) + } + } + }), + defineMethod({ + name: 'plugins.readPanelEntry', + params: PluginReadPanelEntryParams, + handler: async (params, context): Promise => { + const service = requirePluginService() + await service.whenReady() + const ownerKey = bindRpcPanelOwner(service, context) + const entry = await service.panels.open(ownerKey, params.pluginKey, params.panelId) + if (context.signal?.aborted) { + service.panels.revokeOwner(ownerKey) + return null + } + return entry + } + }), + defineMethod({ + name: 'plugins.invokeCommand', + params: PluginInvokeCommandParams, + handler: async (params) => { + const service = requirePluginService() + await service.whenReady() + return service.invokeCommand(params.pluginKey, params.commandId, params.args) + } + }) +] diff --git a/src/main/runtime/rpc/methods/session-tab-close-methods.ts b/src/main/runtime/rpc/methods/session-tab-close-methods.ts index 0a3af68a90f..e11e2b78aa5 100644 --- a/src/main/runtime/rpc/methods/session-tab-close-methods.ts +++ b/src/main/runtime/rpc/methods/session-tab-close-methods.ts @@ -1,4 +1,5 @@ import { withSpan } from '../../../observability/tracer' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { defineMethod, type RpcAnyMethod } from '../core' import { CloseLifecycleTab, CloseTab } from './session-tabs-schemas' @@ -6,11 +7,17 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ defineMethod({ name: 'session.tabs.close', params: CloseTab, - handler: async (params, context) => - withSpan( + handler: async (params, context) => { + const requiresIntent = + context.clientKind === undefined || + (context.clientKind === 'runtime' && + context.clientCapabilities?.includes(SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY) === + true) + return withSpan( 'runtime.session-tabs.close', async (span) => { - if (!params.reason && context.clientKind === undefined) { + // Why: old runtime clicks and cleanup are wire-identical, so changing their behavior would regress mixed-version pairings. + if (!params.reason && requiresIntent) { const result = await context.runtime.refuseUnattributedMobileSessionTabClose( params.worktree, params.tabId @@ -36,12 +43,17 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [ origin: context.clientKind ?? 'in-process', closeReason: params.reason ?? - (context.clientKind ? `legacy-${context.clientKind}-user` : 'missing'), + (requiresIntent + ? 'missing' + : context.clientKind === 'mobile' + ? 'legacy-mobile-user' + : 'legacy-runtime-user'), connectionGeneration: context.connectionId ?? 'in-process', requestId: context.requestId ?? 'in-process' } } ) + } }), defineMethod({ name: 'session.tabs.closeLifecycle', diff --git a/src/main/runtime/rpc/methods/session-tabs.test.ts b/src/main/runtime/rpc/methods/session-tabs.test.ts index eb70d393c0d..b9496b3da54 100644 --- a/src/main/runtime/rpc/methods/session-tabs.test.ts +++ b/src/main/runtime/rpc/methods/session-tabs.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { RpcDispatcher } from '../dispatcher' import type { RpcRequest } from '../core' import type { OrcaRuntimeService } from '../../orca-runtime' +import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { SESSION_TAB_METHODS } from './session-tabs' function makeRequest(method: string, params?: unknown): RpcRequest { @@ -114,6 +115,32 @@ describe('session tab RPC methods', () => { expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() }) + it('preserves explicit user closes from current runtime clients', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn(), + closeMobileSessionTab: vi.fn().mockResolvedValue({ closed: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.close', { + worktree: 'id:wt-1', + tabId: 'tab-1', + reason: 'user' + }), + (response) => replies.push(response), + { clientKind: 'runtime', pairedDeviceId: 'current-runtime' } + ) + + expect(replies).toHaveLength(1) + expect(runtime.closeMobileSessionTab).toHaveBeenCalledWith('id:wt-1', 'tab-1', { + reason: 'user' + }) + expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() + }) + it('preserves reasonless explicit closes from authenticated legacy mobile clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -136,7 +163,7 @@ describe('session tab RPC methods', () => { expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() }) - it('preserves reasonless explicit closes from authenticated legacy runtime clients', async () => { + it('preserves reasonless closes from authenticated legacy runtime clients', async () => { const runtime = { getRuntimeId: () => 'test-runtime', refuseUnattributedMobileSessionTabClose: vi.fn(), @@ -158,6 +185,35 @@ describe('session tab RPC methods', () => { expect(runtime.refuseUnattributedMobileSessionTabClose).not.toHaveBeenCalled() }) + it('refuses reasonless closes from runtime clients that negotiated explicit intent', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + refuseUnattributedMobileSessionTabClose: vi.fn().mockResolvedValue({ + closed: true, + refused: true, + refusalReason: 'missing-intent', + snapshotRepublished: true + }), + closeMobileSessionTab: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS }) + const replies: string[] = [] + + await dispatcher.dispatchStreaming( + makeRequest('session.tabs.close', { worktree: 'id:wt-1', tabId: 'tab-1' }), + (response) => replies.push(response), + { + clientKind: 'runtime', + pairedDeviceId: 'current-runtime', + clientCapabilities: [SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY] + } + ) + + expect(replies).toHaveLength(1) + expect(runtime.refuseUnattributedMobileSessionTabClose).toHaveBeenCalledWith('id:wt-1', 'tab-1') + expect(runtime.closeMobileSessionTab).not.toHaveBeenCalled() + }) + it.each(['pty-exit', 'cleanup'] as const)( 'rejects %s on the legacy close endpoint before host adjudication', async (reason) => { diff --git a/src/main/runtime/rpc/methods/task-resume-state-schema.ts b/src/main/runtime/rpc/methods/task-resume-state-schema.ts new file mode 100644 index 00000000000..825248ac4e7 --- /dev/null +++ b/src/main/runtime/rpc/methods/task-resume-state-schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import type { TaskResumeState as TaskResumeStateType } from '../../../../shared/types' +import type { AssertNoMissingKeys } from './ui-state-schema-parity' + +/** Tasks page-position state persisted through `ui.set`; mirrors `TaskResumeState`. */ +export const TaskResumeState = z + .object({ + githubMode: z.enum(['items', 'project']).optional(), + githubItemsPreset: z.string().nullable().optional(), + githubItemsQuery: z.string().optional(), + githubProjectHiddenFieldIdsByView: z.record(z.string(), z.array(z.string())).optional(), + linearMode: z.enum(['issues', 'projects', 'views']).optional(), + linearPreset: z.enum(['assigned', 'created', 'all', 'completed']).optional(), + linearQuery: z.string().optional(), + linearContext: z + .object({ + kind: z.enum(['project', 'view']), + id: z.string(), + workspaceId: z.string(), + model: z.enum(['issue', 'project']).optional() + }) + .strict() + .optional(), + jiraPreset: z.enum(['assigned', 'reported', 'all', 'done']).optional(), + jiraQuery: z.string().optional() + }) + .strict() + +const _taskResumeStateParity: AssertNoMissingKeys< + TaskResumeStateType, + z.infer +> = true +void _taskResumeStateParity diff --git a/src/main/runtime/rpc/methods/ui-state-schema-parity.ts b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts new file mode 100644 index 00000000000..5ed5e5d91ca --- /dev/null +++ b/src/main/runtime/rpc/methods/ui-state-schema-parity.ts @@ -0,0 +1,14 @@ +/** + * The client-facing UI schemas are `.strict()`, so Zod rejects an unlisted key + * instead of stripping it — the dispatcher then fails the WHOLE `ui.set` payload + * with `invalid_argument`. A field added to `PersistedUIState`/`TaskResumeState` + * without a matching schema entry therefore silently breaks every paired + * web/mobile/relay client while desktop (schema-less `ui:set` IPC) stays green. + * + * Assigning `true` to this type turns that runtime drift into a typecheck error + * that names the missing key. + */ +export type AssertNoMissingKeys> = + Exclude extends never + ? true + : { missingFromSchema: Exclude } diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts index faeb9b9afb3..4f7db9fb6a3 100644 --- a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -5,6 +5,7 @@ import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-reque export type MobileE2EEAuth = { type: 'e2ee_auth' deviceToken: string + clientCapabilities?: unknown v?: 2 transcriptHashB64?: string } @@ -16,6 +17,7 @@ export function isValidMobileE2EEAuthVersion( if (!v2Session) { return auth.v === undefined && auth.transcriptHashB64 === undefined } + // Why: mobile v2 keeps an exact transcript-bound shape; runtime capabilities use legacy paired-runtime auth. return ( Object.keys(auth).sort().join(',') === 'deviceToken,transcriptHashB64,type,v' && auth.v === 2 && @@ -27,7 +29,9 @@ export function authenticateMobileE2EE( plaintext: string v2Session: DesktopMobileE2EEV2Session | null resolveDevice: (token: string) => TDevice | null -}): { ok: true; device: TDevice } | { ok: false; code: 'bad_auth' | 'unauthorized' } { +}): + | { ok: true; device: TDevice; auth: MobileE2EEAuth } + | { ok: false; code: 'bad_auth' | 'unauthorized' } { let auth: MobileE2EEAuth try { auth = parseRemoteRuntimeJsonText(args.plaintext) as MobileE2EEAuth @@ -43,7 +47,7 @@ export function authenticateMobileE2EE( } const device = args.resolveDevice(auth.deviceToken) return device?.deviceToken === auth.deviceToken - ? { ok: true, device } + ? { ok: true, device, auth } : { ok: false, code: 'unauthorized' } } diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts index f9d71c85be7..ac2d925093d 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -49,7 +49,11 @@ class FakeTransport implements MobileSocketTransport { } } -function registryFor(deviceId: string, token: string): DeviceRegistry { +function registryFor( + deviceId: string, + token: string, + scope: 'mobile' | 'runtime' = 'mobile' +): DeviceRegistry { return { validateToken: (candidate: string) => candidate === token @@ -57,7 +61,7 @@ function registryFor(deviceId: string, token: string): DeviceRegistry { deviceId, token, name: 'Phone', - scope: 'mobile' as const, + scope, pairedAt: 1, lastSeenAt: 0 } @@ -138,7 +142,7 @@ describe('MobileSocketWiring', () => { const onText = vi.fn() const onClose = vi.fn() const wiring = new MobileSocketWiring({ - deviceRegistry: registryFor('device-1', 'valid-token'), + deviceRegistry: registryFor('device-1', 'valid-token', 'runtime'), e2eeKeypair: { publicKey: desktop.publicKey, secretKey: desktop.secretKey, @@ -160,14 +164,22 @@ describe('MobileSocketWiring', () => { const sharedKey = deriveSharedKey(phone.secretKey, desktop.publicKey) transport.receive( ws, - encrypt(JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), sharedKey) + encrypt( + JSON.stringify({ + type: 'e2ee_auth', + deviceToken: 'valid-token', + clientCapabilities: ['session-tabs.close-intent.v1'] + }), + sharedKey + ) ) transport.receive(ws, encrypt('{"id":"rpc-1","method":"status.get"}', sharedKey)) expect(transport.setClientId).toHaveBeenCalledWith(ws, 'valid-token') expect(onText).toHaveBeenCalledOnce() expect(onText.mock.calls[0]?.[0]).toMatchObject({ - device: { deviceId: 'device-1', deviceToken: 'valid-token', scope: 'mobile' }, + device: { deviceId: 'device-1', deviceToken: 'valid-token', scope: 'runtime' }, + clientCapabilities: ['session-tabs.close-intent.v1'], transport: { transport: 'direct' } }) diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 3235bb8f94a..0ca490c1a56 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -4,6 +4,7 @@ import type { DeviceEntry, DeviceRegistry } from '../device-registry' import type { E2EEKeypair } from '../e2ee-keypair' import { E2EEChannel, type E2EEAuthenticatedDevice } from './e2ee-channel' import { createMobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget' +import type { RuntimeCapability } from '../../../shared/protocol-version' type MobileSocketPayload = string | Uint8Array @@ -36,6 +37,7 @@ export type AuthenticatedMobileSocket = { ws: WebSocket connectionId: string device: E2EEAuthenticatedDevice + clientCapabilities: readonly RuntimeCapability[] transport: MobileSocketTransportMetadata } @@ -158,8 +160,14 @@ export class MobileSocketWiring { } return toAuthenticatedDevice(device) }, - onReady: (_channel, device) => { - const socket = { ws, connectionId, device, transport: metadata } + onReady: (channel, device) => { + const socket = { + ws, + connectionId, + device, + clientCapabilities: channel.clientCapabilities, + transport: metadata + } this.authenticatedSockets.set(ws, socket) transport.setClientId(ws, device.deviceToken) this.deviceRegistry.updateLastSeen(device.deviceId) diff --git a/src/main/runtime/rpc/orchestration-contract-fence.test.ts b/src/main/runtime/rpc/orchestration-contract-fence.test.ts new file mode 100644 index 00000000000..a6618542d9c --- /dev/null +++ b/src/main/runtime/rpc/orchestration-contract-fence.test.ts @@ -0,0 +1,138 @@ +import { createHash } from 'node:crypto' +import { z } from 'zod' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationDb } from '../orchestration/db' +import { defineMethod, type RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' + +describe('orchestration contract fence', () => { + const databases: OrchestrationDb[] = [] + + afterEach(() => { + for (const database of databases.splice(0)) { + database.close() + } + }) + + function createHarness(method = 'orchestration.send') { + const database = new OrchestrationDb(':memory:') + databases.push(database) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(database) + const effect = vi.fn(() => ({ accepted: true })) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [ + defineMethod({ + name: method, + params: z.object({ subject: z.string() }), + handler: effect + }) + ] + }) + return { database, dispatcher, effect } + } + + function request(overrides: Partial = {}): RpcRequest { + return { + id: 'rpc_1', + authToken: 'caller-token', + method: 'orchestration.send', + params: { subject: 'hello' }, + orchestrationRequestId: 'mutation_1', + ...overrides + } + } + + it.each([ + [undefined, 'client_contract_missing'], + [0, 'client_contract_unsupported'], + [ORCHESTRATION_CONTRACT_VERSION + 1, 'client_contract_unsupported'] + ])( + 'rejects contract version %s before parsing, receipts, or effects', + async (version, reason) => { + const { database, dispatcher, effect } = createHarness() + const response = await dispatcher.dispatch( + request({ + params: { malformed: true }, + orchestrationContractVersion: version + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'orchestration_migration_required', + data: { + reason, + effectsApplied: false, + nextCommandArgs: ['skills', 'get', 'orchestration', '--full'] + } + } + }) + expect(effect).not.toHaveBeenCalled() + const callerFingerprint = createHash('sha256').update('caller-token').digest('hex') + expect(database.getMutationReceipt(callerFingerprint, 'mutation_1')).toBeUndefined() + } + ) + + it('allows the current contract to reach the mutation executor', async () => { + const { dispatcher, effect } = createHarness() + const response = await dispatcher.dispatch( + request({ orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION }) + ) + + expect(response).toMatchObject({ ok: true, result: { accepted: true } }) + expect(effect).toHaveBeenCalledOnce() + }) + + it('keeps read-only orchestration inspection available without a contract', async () => { + const { dispatcher, effect } = createHarness('orchestration.taskList') + const response = await dispatcher.dispatch( + request({ + method: 'orchestration.taskList', + params: { subject: 'read' }, + orchestrationRequestId: undefined + }) + ) + + expect(response).toMatchObject({ ok: true, result: { accepted: true } }) + expect(effect).toHaveBeenCalledOnce() + }) + + it.each(['orchestration.run', 'orchestration.runStop'])( + 'retires %s even when the caller sends the current contract', + async (method) => { + const { dispatcher, effect } = createHarness(method) + const response = await dispatcher.dispatch( + request({ + method, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION + }) + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'orchestration_migration_required', + data: { reason: 'command_retired', effectsApplied: false } + } + }) + expect(effect).not.toHaveBeenCalled() + } + ) + + it('applies the same pre-effect fence on WebSocket dispatch', async () => { + const { dispatcher, effect } = createHarness() + const replies: string[] = [] + await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply)) + + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: false, + error: { code: 'orchestration_migration_required' } + }) + expect(effect).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/runtime/rpc/orchestration-contract-fence.ts b/src/main/runtime/rpc/orchestration-contract-fence.ts new file mode 100644 index 00000000000..ba266fb6d2c --- /dev/null +++ b/src/main/runtime/rpc/orchestration-contract-fence.ts @@ -0,0 +1,36 @@ +import { + isOrchestrationMutation, + isRetiredOrchestrationMethod, + orchestrationMigrationData, + type OrchestrationMigrationReason +} from '../../../shared/orchestration-rpc-contract' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import type { RpcEnvelopeMeta, RpcRequest, RpcResponse } from './core' +import { errorResponse } from './errors' + +export function orchestrationMigrationFence( + request: RpcRequest, + meta: RpcEnvelopeMeta +): RpcResponse | undefined { + if (!isOrchestrationMutation(request.method, request.params)) { + return undefined + } + let reason: OrchestrationMigrationReason | undefined + if (isRetiredOrchestrationMethod(request.method)) { + reason = 'command_retired' + } else if (request.orchestrationContractVersion === undefined) { + reason = 'client_contract_missing' + } else if (request.orchestrationContractVersion !== ORCHESTRATION_CONTRACT_VERSION) { + reason = 'client_contract_unsupported' + } + if (!reason) { + return undefined + } + return errorResponse( + request.id, + meta, + 'orchestration_migration_required', + 'This orchestration mutation uses an obsolete contract. No effects were applied.', + orchestrationMigrationData(reason) + ) +} diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts new file mode 100644 index 00000000000..0ec659cc02d --- /dev/null +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -0,0 +1,152 @@ +import { createHash } from 'node:crypto' +import { isOrchestrationMutation } from '../../../shared/orchestration-rpc-contract' +import type { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationError } from '../orchestration/orchestration-error' +import type { RpcRequest } from './core' + +export type DurableMutationInvocation = { + identity: { + callerFingerprint: string + requestId: string + method: string + payloadHash: string + } + recordReceipt: (receipt: unknown) => void +} + +export class OrchestrationMutationExecutor { + private readonly inFlight = new Map>() + + constructor(private readonly runtime: OrcaRuntimeService) {} + + async run( + request: RpcRequest, + params: unknown, + invoke: (mutation?: DurableMutationInvocation) => Promise | unknown + ): Promise { + const requestId = request.orchestrationRequestId + if (!requestId || !isOrchestrationMutation(request.method, params)) { + return await invoke() + } + const callerFingerprint = authenticatedCallerFingerprint(request) + const payloadHash = createHash('sha256') + .update(JSON.stringify(canonicalize({ method: request.method, params }))) + .digest('hex') + const key = `${callerFingerprint}:${requestId}` + const db = this.runtime.getOrchestrationDb() + const identity = { callerFingerprint, requestId, method: request.method, payloadHash } + const atomicWorkerAcceptance = + request.method === 'orchestration.workerStart' || + request.method === 'orchestration.federationAttachStart' + const begun = atomicWorkerAcceptance + ? (() => { + const row = db.getMutationReceipt(callerFingerprint, requestId) + if (!row) { + return { disposition: 'started' as const } + } + if (row.method !== request.method || row.payload_hash !== payloadHash) { + throw new OrchestrationError( + 'request_mismatch', + `Mutation request ${requestId} was already used with different input.` + ) + } + return { disposition: row.state, row } + })() + : db.beginMutationReceipt(identity) + + if (begun.disposition === 'completed') { + return attachMutationReceipt(JSON.parse(begun.row.receipt ?? 'null'), requestId, true) + } + if (begun.disposition === 'pending') { + const active = this.inFlight.get(key) + if (active) { + return attachMutationReceipt(await active, requestId, true) + } + const recovery = getPendingWorkerStartRecovery(request.method, begun.row.receipt) + throw new OrchestrationError( + 'operation_unknown', + recovery + ? `Worker start ${requestId} was accepted as Dispatch ${recovery.dispatchId} before restart. Inspect that Dispatch; do not start another worker.` + : `Mutation ${requestId} may have been accepted before restart. Retry inspection or recovery with the same request ID.`, + recovery + ? { + requestId, + dispatchId: recovery.dispatchId, + recoveryCommand: `orca orchestration worker-show --dispatch ${recovery.dispatchId} --json` + } + : { requestId } + ) + } + + const recordReceipt = (result: unknown): void => { + db.completeMutationReceipt({ + ...identity, + receipt: JSON.stringify(attachMutationReceipt(result, requestId, false)) + }) + } + const active = Promise.resolve().then(() => invoke({ identity, recordReceipt })) + this.inFlight.set(key, active) + try { + const result = await active + const receipted = attachMutationReceipt(result, requestId, false) + db.completeMutationReceipt({ ...identity, receipt: JSON.stringify(receipted) }) + return receipted + } catch (error) { + if (!(error instanceof OrchestrationError && error.code === 'operation_unknown')) { + db.discardPendingMutationReceipt(callerFingerprint, requestId) + } + throw error + } finally { + this.inFlight.delete(key) + } + } +} + +export function authenticatedCallerFingerprint(request: RpcRequest): string { + const callerToken = + request.authToken || + (request as RpcRequest & { deviceToken?: string }).deviceToken || + 'authenticated_transport' + return createHash('sha256').update(callerToken).digest('hex') +} + +function canonicalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalize) + } + if (!value || typeof value !== 'object') { + return value + } + const source = value as Record + const result: Record = {} + for (const key of Object.keys(source).sort()) { + if (source[key] !== undefined) { + result[key] = canonicalize(source[key]) + } + } + return result +} + +function attachMutationReceipt(result: unknown, requestId: string, replayed: boolean): unknown { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return { result, mutation: { requestId, replayed } } + } + return { ...(result as Record), mutation: { requestId, replayed } } +} + +function getPendingWorkerStartRecovery( + method: string, + receipt: string | null +): { dispatchId: string } | undefined { + if (method !== 'orchestration.workerStart' || !receipt) { + return undefined + } + try { + const parsed = JSON.parse(receipt) as { accepted?: { dispatchId?: unknown } } + return typeof parsed.accepted?.dispatchId === 'string' + ? { dispatchId: parsed.accepted.dispatchId } + : undefined + } catch { + return undefined + } +} diff --git a/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts new file mode 100644 index 00000000000..3407211ba6f --- /dev/null +++ b/src/main/runtime/rpc/orchestration-mutation-ledger.test.ts @@ -0,0 +1,309 @@ +import { createHash } from 'node:crypto' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { z } from 'zod' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationDb } from '../orchestration/db' +import { defineMethod, type RpcRequest } from './core' +import { RpcDispatcher } from './dispatcher' +import { ORCHESTRATION_METHODS } from './methods/orchestration' + +const Params = z.object({ subject: z.string() }) + +function request(params: { + rpcId: string + mutationId: string + subject: string + authToken?: string +}): RpcRequest { + return { + id: params.rpcId, + authToken: params.authToken ?? 'caller-token', + method: 'orchestration.send', + params: { subject: params.subject }, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: params.mutationId + } +} + +describe('durable orchestration mutation ledger', () => { + const paths: string[] = [] + + afterEach(() => { + for (const path of paths.splice(0)) { + rmSync(path, { recursive: true, force: true }) + } + }) + + function createHarness(dbPath: string | ':memory:' = ':memory:') { + const db = new OrchestrationDb(dbPath) + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const effect = vi.fn((subject: string) => + db.insertMessage({ from: 'caller', to: 'recipient', subject }) + ) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [ + defineMethod({ + name: 'orchestration.send', + params: Params, + handler: ({ subject }) => ({ message: effect(subject) }) + }) + ] + }) + return { db, runtime, dispatcher, effect } + } + + it('replays one completed receipt without repeating the effect', async () => { + const { db, dispatcher, effect } = createHarness() + const first = await dispatcher.dispatch( + request({ rpcId: 'rpc_1', mutationId: 'mutation_1', subject: 'hello' }) + ) + const replay = await dispatcher.dispatch( + request({ rpcId: 'rpc_2', mutationId: 'mutation_1', subject: 'hello' }) + ) + + expect(first).toMatchObject({ + ok: true, + result: { message: { subject: 'hello' }, mutation: { replayed: false } } + }) + expect(replay).toMatchObject({ + ok: true, + result: { message: { subject: 'hello' }, mutation: { replayed: true } } + }) + expect(effect).toHaveBeenCalledTimes(1) + expect(db.getInbox(10)).toHaveLength(1) + db.close() + }) + + it('rejects changed input for the same caller and request ID', async () => { + const { db, dispatcher } = createHarness() + await dispatcher.dispatch( + request({ rpcId: 'rpc_1', mutationId: 'mutation_1', subject: 'hello' }) + ) + const mismatch = await dispatcher.dispatch( + request({ rpcId: 'rpc_2', mutationId: 'mutation_1', subject: 'changed' }) + ) + + expect(mismatch).toMatchObject({ ok: false, error: { code: 'request_mismatch' } }) + db.close() + }) + + it('applies the same ledger on authenticated WebSocket dispatch', async () => { + const { db, dispatcher, effect } = createHarness() + const replies: string[] = [] + const firstRequest = request({ + rpcId: 'rpc_1', + mutationId: 'mutation_remote', + subject: 'remote' + }) as RpcRequest & { deviceToken?: string } + firstRequest.authToken = '' + firstRequest.deviceToken = 'paired-device' + await dispatcher.dispatchStreaming(firstRequest, (reply) => replies.push(reply)) + const replayRequest = { ...firstRequest, id: 'rpc_2' } + await dispatcher.dispatchStreaming(replayRequest, (reply) => replies.push(reply)) + + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: true, + result: { mutation: { replayed: false } } + }) + expect(JSON.parse(replies[1] ?? '{}')).toMatchObject({ + ok: true, + result: { mutation: { replayed: true } } + }) + expect(effect).toHaveBeenCalledTimes(1) + db.close() + }) + + it('joins concurrent identical mutations', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const effect = vi.fn(async () => { + await gate + return { accepted: true } + }) + const dispatcher = new RpcDispatcher({ + runtime, + methods: [ + defineMethod({ + name: 'orchestration.send', + params: Params, + handler: effect + }) + ] + }) + const first = dispatcher.dispatch( + request({ rpcId: 'rpc_1', mutationId: 'mutation_join', subject: 'same' }) + ) + await Promise.resolve() + const second = dispatcher.dispatch( + request({ rpcId: 'rpc_2', mutationId: 'mutation_join', subject: 'same' }) + ) + release?.() + + expect(await first).toMatchObject({ ok: true, result: { mutation: { replayed: false } } }) + expect(await second).toMatchObject({ ok: true, result: { mutation: { replayed: true } } }) + expect(effect).toHaveBeenCalledTimes(1) + db.close() + }) + + it('replays a completed receipt after database and dispatcher restart', async () => { + const dir = mkdtempSync(join(tmpdir(), 'orca-mutation-ledger-')) + paths.push(dir) + const dbPath = join(dir, 'orchestration.db') + const first = createHarness(dbPath) + await first.dispatcher.dispatch( + request({ rpcId: 'rpc_1', mutationId: 'mutation_1', subject: 'durable' }) + ) + first.db.close() + + const second = createHarness(dbPath) + const replay = await second.dispatcher.dispatch( + request({ rpcId: 'rpc_2', mutationId: 'mutation_1', subject: 'durable' }) + ) + expect(replay).toMatchObject({ + ok: true, + result: { message: { subject: 'durable' }, mutation: { replayed: true } } + }) + expect(second.effect).not.toHaveBeenCalled() + second.db.close() + }) + + it('returns unknown for a pending receipt left by a previous process', async () => { + const { db, dispatcher } = createHarness() + db.beginMutationReceipt({ + callerFingerprint: createHash('sha256').update('caller-token').digest('hex'), + requestId: 'mutation_1', + method: 'orchestration.send', + payloadHash: createHash('sha256') + .update('{"method":"orchestration.send","params":{"subject":"hello"}}') + .digest('hex') + }) + + const result = await dispatcher.dispatch( + request({ rpcId: 'rpc_1', mutationId: 'mutation_1', subject: 'hello' }) + ) + expect(result).toMatchObject({ ok: false, error: { code: 'operation_unknown' } }) + db.close() + }) + + it('returns the accepted Dispatch when worker-start was interrupted by restart', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + const params = { from: 'term_coord', task: db.createTask({ spec: 'restart' }).id } + const callerFingerprint = createHash('sha256').update('caller-token').digest('hex') + const payloadHash = createHash('sha256') + .update(JSON.stringify({ method: 'orchestration.workerStart', params })) + .digest('hex') + const started = db.createStartingWorkerDispatch({ + taskId: params.task, + startOptions: {}, + mutationReceipt: { + callerFingerprint, + requestId: 'mutation_worker_start', + method: 'orchestration.workerStart', + payloadHash + } + }) + const effect = vi.fn() + const dispatcher = new RpcDispatcher({ + runtime, + methods: [ + defineMethod({ + name: 'orchestration.workerStart', + params: z.object({ from: z.string(), task: z.string() }), + handler: effect + }) + ] + }) + + const result = await dispatcher.dispatch({ + id: 'rpc_worker_start_retry', + authToken: 'caller-token', + method: 'orchestration.workerStart', + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'mutation_worker_start' + }) + + expect(result).toMatchObject({ + ok: false, + error: { + code: 'operation_unknown', + data: { + requestId: 'mutation_worker_start', + dispatchId: started.dispatch.id, + recoveryCommand: `orca orchestration worker-show --dispatch ${started.dispatch.id} --json` + } + } + }) + expect(effect).not.toHaveBeenCalled() + db.close() + }) + + it('recovers a lost ask acceptance without creating a second question', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_worker:leaf_worker') + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('runtime:pty:1') + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'waitForMessage').mockImplementation( + async (_address, options) => + await new Promise<'cancelled'>((resolve) => { + options?.signal?.addEventListener('abort', () => resolve('cancelled'), { once: true }) + }) + ) + const run = db.createRun({ + objective: 'Ask recovery', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'ask', runId: run.id }) + const dispatch = db.createDispatchContext(task.id, 'term_worker', 'tab_worker:leaf_worker') + const capability = db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: 'tab_worker:leaf_worker', + processIncarnation: 'runtime:pty:1' + }) + const askRequest: RpcRequest = { + id: 'rpc_ask_1', + authToken: 'caller-token', + method: 'orchestration.ask', + params: { from: 'term_worker', question: 'Proceed?', timeoutMs: 60_000 }, + orchestrationCapability: capability, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: 'mutation_ask' + } + const controller = new AbortController() + const firstDispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const first = firstDispatcher.dispatch(askRequest, { signal: controller.signal }) + await vi.waitFor(() => expect(db.getInbox(10)).toHaveLength(1)) + + const restartedDispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + const recovered = await restartedDispatcher.dispatch({ ...askRequest, id: 'rpc_ask_2' }) + expect(recovered).toMatchObject({ + ok: true, + result: { + accepted: true, + messageId: expect.stringMatching(/^msg_/), + mutation: { requestId: 'mutation_ask', replayed: true } + } + }) + expect(db.getInbox(10)).toHaveLength(1) + + controller.abort() + await first + db.close() + }) +}) diff --git a/src/main/runtime/rpc/runtime-client-capabilities.test.ts b/src/main/runtime/rpc/runtime-client-capabilities.test.ts new file mode 100644 index 00000000000..b463ccd837a --- /dev/null +++ b/src/main/runtime/rpc/runtime-client-capabilities.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseRuntimeClientCapabilities } from './runtime-client-capabilities' + +describe('parseRuntimeClientCapabilities', () => { + it('accepts a bounded string array', () => { + expect(parseRuntimeClientCapabilities(['session-tabs.close-intent.v1', 'future.v1'])).toEqual([ + 'session-tabs.close-intent.v1', + 'future.v1' + ]) + }) + + it.each([ + undefined, + 'session-tabs.close-intent.v1', + [7], + [''], + ['x'.repeat(129)], + Array.from({ length: 65 }, () => 'future.v1') + ])('rejects malformed or oversized capability input', (value) => { + expect(parseRuntimeClientCapabilities(value)).toEqual([]) + }) +}) diff --git a/src/main/runtime/rpc/runtime-client-capabilities.ts b/src/main/runtime/rpc/runtime-client-capabilities.ts new file mode 100644 index 00000000000..2bda34ae1e1 --- /dev/null +++ b/src/main/runtime/rpc/runtime-client-capabilities.ts @@ -0,0 +1,12 @@ +import type { RuntimeCapability } from '../../../shared/protocol-version' + +export function parseRuntimeClientCapabilities(value: unknown): readonly RuntimeCapability[] { + if (!Array.isArray(value) || value.length > 64) { + return [] + } + const capabilities = value.filter( + (capability): capability is RuntimeCapability => + typeof capability === 'string' && capability.length > 0 && capability.length <= 128 + ) + return capabilities.length === value.length ? capabilities : [] +} diff --git a/src/main/runtime/rpc/runtime-feature-interaction.ts b/src/main/runtime/rpc/runtime-feature-interaction.ts new file mode 100644 index 00000000000..2a25bff1080 --- /dev/null +++ b/src/main/runtime/rpc/runtime-feature-interaction.ts @@ -0,0 +1,44 @@ +import type { FeatureInteractionId } from '../../../shared/feature-interactions' +import { isBrowserPaneUiRuntimeRpcParams } from '../../../shared/runtime-rpc-feature-interaction-source' + +export function getRuntimeFeatureInteractionId( + method: string, + result: unknown, + rawParams?: unknown +): FeatureInteractionId | null { + if (method === 'browser.profileImportFromBrowser') { + return hasBooleanResult(result, 'ok') ? 'cookie-import' : null + } + if (method === 'browser.profileClearDefaultCookies') { + return hasBooleanResult(result, 'cleared') ? 'cookie-import' : null + } + if (method === 'browser.screencast.unsubscribe') { + return null + } + if (method.startsWith('browser.') && isBrowserPaneUiRuntimeRpcParams(rawParams)) { + return null + } + if (method.startsWith('browser.') && !method.startsWith('browser.profile')) { + return 'agent-browser-use' + } + if (method.startsWith('emulator.')) { + return null + } + if (method === 'computer.permissions') { + return 'computer-use-setup' + } + if ( + method.startsWith('computer.') && + method !== 'computer.capabilities' && + method !== 'computer.permissionsStatus' + ) { + return 'computer-use' + } + return method.startsWith('orchestration.') ? 'agent-orchestration' : null +} + +function hasBooleanResult(value: unknown, key: string): boolean { + return ( + value !== null && typeof value === 'object' && (value as Record)[key] === true + ) +} diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index c143d4f75d8..2ed908bba4c 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -26,6 +26,7 @@ import { import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { DeviceRegistry } from './device-registry' import { DEVICE_REGISTRY_FILENAME, E2EE_KEYPAIR_FILENAME } from './mobile-pairing-files' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' vi.mock('../git/worktree', () => ({ listWorktrees: vi.fn().mockResolvedValue([ @@ -60,7 +61,7 @@ async function sendRequest( resolve(JSON.parse(message) as Record) }) socket.on('connect', () => { - socket.write(`${JSON.stringify(request)}\n`) + socket.write(`${JSON.stringify(withCurrentOrchestrationContract(request))}\n`) }) }) } @@ -111,12 +112,20 @@ function openFramedSession(endpoint: string, request: Record): } }) socket.on('connect', () => { - socket.write(`${JSON.stringify(request)}\n`) + socket.write(`${JSON.stringify(withCurrentOrchestrationContract(request))}\n`) }) }) return { socket, frames, done } } +function withCurrentOrchestrationContract( + request: Record +): Record { + return typeof request.method === 'string' && request.method.startsWith('orchestration.') + ? { ...request, orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION } + : request +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -131,6 +140,18 @@ async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(endpoint) @@ -1285,12 +1306,14 @@ describe('OrcaRuntimeRpcServer', () => { try { const first = server['handleWebSocketMessage']( - JSON.stringify({ - id: 'req_wait', - method: 'orchestration.check', - deviceToken: entry.token, - params: { terminal: 'term_wait', wait: true, timeoutMs: 10_000 } - }), + JSON.stringify( + withCurrentOrchestrationContract({ + id: 'req_wait', + method: 'orchestration.check', + deviceToken: entry.token, + params: { terminal: 'term_wait', wait: true, timeoutMs: 10_000 } + }) + ), (response) => replies.push(JSON.parse(response) as Record), () => {}, undefined, @@ -1300,12 +1323,14 @@ describe('OrcaRuntimeRpcServer', () => { await waitFor(() => server['activeLongPolls'] === 1) await server['handleWebSocketMessage']( - JSON.stringify({ - id: 'req_busy', - method: 'orchestration.check', - deviceToken: entry.token, - params: { terminal: 'term_busy', wait: true, timeoutMs: 10_000 } - }), + JSON.stringify( + withCurrentOrchestrationContract({ + id: 'req_busy', + method: 'orchestration.check', + deviceToken: entry.token, + params: { terminal: 'term_busy', wait: true, timeoutMs: 10_000 } + }) + ), (response) => replies.push(JSON.parse(response) as Record), () => {}, undefined, @@ -1338,6 +1363,7 @@ describe('OrcaRuntimeRpcServer', () => { const runtime = new OrcaRuntimeService() const db = new OrchestrationDb(':memory:') runtime.setOrchestrationDb(db) + seedSupervisedAskWorkers(db, ['term_w0', 'term_w1', 'term_w2']) // Why: cap 4 → ask sub-cap 2, so the third ask must be shed while waits keep the other half. const server = new OrcaRuntimeRpcServer({ runtime, @@ -1358,7 +1384,9 @@ describe('OrcaRuntimeRpcServer', () => { } const dispatch = (id: string, method: string, params: unknown): Promise => server['handleWebSocketMessage']( - JSON.stringify({ id, method, deviceToken: entry.token, params }), + JSON.stringify( + withCurrentOrchestrationContract({ id, method, deviceToken: entry.token, params }) + ), push, () => {}, undefined, @@ -4032,6 +4060,17 @@ describe('OrcaRuntimeRpcServer', () => { const runtime = new OrcaRuntimeService() const db = new OrchestrationDb(':memory:') runtime.setOrchestrationDb(db) + const askerPaneKey = 'tab_asker:aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === 'term_asker' ? askerPaneKey : null + ) + const run = db.createRun({ + objective: 'Keepalive test', + coordinatorHandle: 'term_nobody', + coordinatorPaneKey: 'tab_coord:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' + }) + const task = db.createTask({ spec: 'Wait for an answer', runId: run.id }) + db.createDispatchContext(task.id, 'term_asker', askerPaneKey) const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, @@ -4380,6 +4419,7 @@ describe('OrcaRuntimeRpcServer', () => { const runtime = new OrcaRuntimeService() const db = new OrchestrationDb(':memory:') runtime.setOrchestrationDb(db) + seedSupervisedAskWorkers(db, ['term_w0', 'term_w1', 'term_w2', 'term_w3']) // Why: cap 4 → ask sub-cap 2, so 4 concurrent asks can only take half the budget. const server = new OrcaRuntimeRpcServer({ runtime, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 6952eb85256..a4ac6470461 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1218,6 +1218,7 @@ export class OrcaRuntimeRpcServer { pairedDeviceId: device.deviceId, // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, + clientCapabilities: authenticatedSocket?.clientCapabilities, pairing: pairingContext, signal: abortRegistration?.signal, sendBinary, diff --git a/src/main/ssh/ssh-relay-build-toolchain.test.ts b/src/main/ssh/ssh-relay-build-toolchain.test.ts index 1442b6bf6a0..b216b599f5a 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.test.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.test.ts @@ -3,6 +3,7 @@ import { buildToolchainProbeCommand, parseBuildToolchainProbe, formatMissingToolchainError, + formatSkippedNodePtyWarning, shouldProbeBuildToolchainAfterNativeDepsFailure } from './ssh-relay-build-toolchain' @@ -109,3 +110,18 @@ describe('formatMissingToolchainError', () => { expect(msg).toContain('sudo pacman -S --needed base-devel python') }) }) + +describe('formatSkippedNodePtyWarning', () => { + it('quotes the tailored install command when a package manager was detected', () => { + const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe('PKG dnf')) + expect(warning).toContain('skipping node-pty') + expect(warning).toContain('sudo dnf install -y make gcc gcc-c++ python3') + }) + + it('stays distro-neutral when no package manager was detected', () => { + // The hint list is the cross-distro menu here; quoting its first line would name Debian on any host. + const warning = formatSkippedNodePtyWarning(parseBuildToolchainProbe('')) + expect(warning).not.toContain('apt-get') + expect(warning).toContain('install a C/C++ toolchain') + }) +}) diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index 208deceb6e0..608fca5c6eb 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -97,10 +97,7 @@ export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string) ) } -export function formatMissingToolchainError( - status: BuildToolchainStatus, - underlyingError: string -): string { +function missingToolNames(status: BuildToolchainStatus): string[] { const present = new Set(status.present) const missing: string[] = [] if (!present.has('make')) { @@ -112,27 +109,54 @@ export function formatMissingToolchainError( if (!hasPython(present)) { missing.push('python3') } + return missing +} +/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */ +export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] { const tailored = status.packageManager ? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install : null + if (tailored) { + return [` ${tailored}`] + } + return [ + ' Debian/Ubuntu: sudo apt-get install -y build-essential python3', + ' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3', + ' Arch: sudo pacman -S --needed base-devel python', + ' Alpine: sudo apk add build-base python3' + ] +} +/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */ +export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string { + // Why: with no package manager detected the hint list is the cross-distro menu, whose first line + // is Debian's — quoting it alone would name the wrong distro, so stay neutral instead. + const hintLines = toolchainInstallHintLines(status) + const hint = + hintLines.length === 1 + ? hintLines[0].trim() + : 'install a C/C++ toolchain (make, a C++ compiler, python3)' + return ( + `missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` + + `connection still serves files and git. Remote terminals need: ${hint}` + ) +} + +export function formatMissingToolchainError( + status: BuildToolchainStatus, + underlyingError: string +): string { const lines = [ - `The remote host is missing the C/C++ build tools (${missing.join(', ')}) needed to ` + - `compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + + `The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` + + `needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` + `prebuilt binary for Linux, so they must be compiled on the remote host.`, '', - 'Install the build tools on the remote host, then reconnect:' + 'Install the build tools on the remote host, then reconnect:', + ...toolchainInstallHintLines(status), + '', + `Underlying install error: ${underlyingError}` ] - if (tailored) { - lines.push(` ${tailored}`) - } else { - lines.push(' Debian/Ubuntu: sudo apt-get install -y build-essential python3') - lines.push(' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3') - lines.push(' Arch: sudo pacman -S --needed base-devel python') - lines.push(' Alpine: sudo apk add build-base python3') - } - lines.push('', `Underlying install error: ${underlyingError}`) return lines.join('\n') } diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index 6b488fdd87e..81165efc4d0 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -40,6 +40,7 @@ import { createSshOperationAbortError, shellEscape } from './ssh-connection-util import { probeBuildToolchain, formatMissingToolchainError, + formatSkippedNodePtyWarning, shouldProbeBuildToolchainAfterNativeDepsFailure } from './ssh-relay-build-toolchain' import { @@ -751,29 +752,32 @@ async function installNativeDeps( resetDeps: RelayNativeDepName[] = [], namespace?: RelayInstallNamespace ): Promise { + const writeRelayPackageJson = async (deps: Record): Promise => { + await writeRelayFile( + conn, + hostPlatform, + joinRemotePath(hostPlatform, remoteDir, 'package.json'), + `${JSON.stringify({ + name: 'orca-relay', + version: '1.0.0', + private: true, + type: 'commonjs', + dependencies: deps, + allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST + })}\n`, + { + signal, + sftpNamespace: namespace + ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json') + : undefined + } + ) + } + // Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127. // Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead. // Why: type:commonjs pins module resolution against Node default flips or a remote ~/.npmrc type=module. - const pkgJson = `${JSON.stringify({ - name: 'orca-relay', - version: '1.0.0', - private: true, - type: 'commonjs', - dependencies: RELAY_NATIVE_DEPS, - allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST - })}\n` - await writeRelayFile( - conn, - hostPlatform, - joinRemotePath(hostPlatform, remoteDir, 'package.json'), - pkgJson, - { - signal, - sftpNamespace: namespace - ? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json') - : undefined - } - ) + await writeRelayPackageJson(RELAY_NATIVE_DEPS) try { const installArgs = Object.entries(RELAY_NATIVE_DEPS) @@ -819,7 +823,46 @@ async function installNativeDeps( if (platform.startsWith('linux') && shouldProbeBuildToolchainAfterNativeDepsFailure(msg)) { const toolchain = await probeBuildToolchain(conn, hostPlatform, signal) if (toolchain?.toolchainMissing) { - throw new Error(formatMissingToolchainError(toolchain, msg)) + // Why: node-pty is the only dep that needs a compiler, and it only backs terminals. Retry + // without it so files/git/editor still connect instead of failing the host outright; a + // missing native dep is already non-fatal below. Rethrow the actionable error if even that + // fails, so a host broken for some other reason still reports the toolchain gap. + console.warn( + `[ssh-relay][NPTY-SKIP-NO-TOOLCHAIN] ${remoteDir} (${platform}): ${formatSkippedNodePtyWarning(toolchain)}` + ) + try { + await installNativeDepsWithoutNodePty( + conn, + remoteDir, + hostPlatform, + nodePath, + writeRelayPackageJson, + resetDeps, + signal + ) + } catch (retryErr) { + if (isUnconfirmedSshCommandTermination(retryErr)) { + throw retryErr + } + signal?.throwIfAborted() + // The thrown toolchain message is built from the original error, so log the retry's own + // cause (registry, ENOSPC, EACCES) rather than losing it. + console.warn( + `[ssh-relay][NPTY-SKIP-RETRY-FAIL] node-pty-less reinstall failed at ${remoteDir} (${platform}): ${(retryErr as Error).message}` + ) + throw new Error(formatMissingToolchainError(toolchain, msg), { cause: retryErr }) + } + // Why: this early return skips the probe below, so verify the dep that does have a prebuilt — + // a @parcel/watcher that installs but can't load would leave file watching silently dead. + await warnIfWatcherUnloadableWithoutNodePty( + conn, + remoteDir, + platform, + hostPlatform, + nodePath, + signal + ) + return } } throw err @@ -897,6 +940,82 @@ function resetNativeDepsCommand( return commands.join('; ') } +/** + * Reinstall the relay's native deps with node-pty dropped, for hosts that cannot compile it. + * + * Why: npm reconciles every dependency in package.json, not just the ones named on the command + * line, so node-pty has to leave the manifest too — naming only @parcel/watcher still rebuilds it. + */ +async function installNativeDepsWithoutNodePty( + conn: SshConnection, + remoteDir: string, + hostPlatform: RemoteHostPlatform, + nodePath: string, + writeRelayPackageJson: (deps: Record) => Promise, + resetDeps: RelayNativeDepName[], + signal?: AbortSignal +): Promise { + const deps = Object.fromEntries( + Object.entries(RELAY_NATIVE_DEPS).filter(([dep]) => dep !== 'node-pty') + ) + await writeRelayPackageJson(deps) + const installArgs = Object.entries(deps) + .map(([dep, version]) => shellEscape(`${dep}@${version}`)) + .join(' ') + // Why: the failed attempt leaves an unbuildable node-pty behind; clear it so npm prunes rather + // than rebuilds it. Keep the caller's resets too — a repair reconnect still needs them. + const resetCommand = resetNativeDepsCommand(hostPlatform, [ + ...new Set([...resetDeps, 'node-pty']) + ]) + // Why: POSIX-only command shape (`;` chaining, `2>&1`) — safe because the only caller is gated on a + // linux platform. Widen that gate and this needs the Windows branch installNativeDeps already has. + await execHostCommand( + conn, + hostPlatform, + commandWithNodePath( + hostPlatform, + nodePath, + remoteDir, + `${resetCommand}; npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` + ), + { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, signal } + ) +} + +/** + * Report an unloadable @parcel/watcher after node-pty was skipped, without failing the connection. + * + * Why: node-pty is expected missing here, but a @parcel/watcher prebuilt that installs and still + * can't require() (glibc below the floor — docs/reference/linux-glibc-compatibility.md) means dead + * file watching. No rebuild: node-pty provably can't compile on this host, so it would only fail. + */ +async function warnIfWatcherUnloadableWithoutNodePty( + conn: SshConnection, + remoteDir: string, + platform: RelayPlatform, + hostPlatform: RemoteHostPlatform, + nodePath: string, + signal?: AbortSignal +): Promise { + try { + const probe = await probeInstalledNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal) + if (probe.missing.includes('@parcel/watcher')) { + console.warn( + `[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED] @parcel/watcher installed but require() failed at ${remoteDir} (${platform}); remote file watching is unavailable. stdout=${probe.output.trim().slice(-200)} stderr=${probe.stderr.trim().slice(-500)}` + ) + } + } catch (err) { + if (isUnconfirmedSshCommandTermination(err)) { + throw err + } + signal?.throwIfAborted() + // Degraded-mode diagnostics must never cost the connection the retry just salvaged. + console.warn( + `[ssh-relay][WATCHER-PROBE-FAIL] native deps probe failed after skipping node-pty at ${remoteDir} (${platform}): ${(err as Error).message}` + ) + } +} + async function rebuildNativeDeps( conn: SshConnection, remoteDir: string, diff --git a/src/main/ssh/ssh-relay-native-deps-install-fixture.ts b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts new file mode 100644 index 00000000000..ea2b1aa073b --- /dev/null +++ b/src/main/ssh/ssh-relay-native-deps-install-fixture.ts @@ -0,0 +1,181 @@ +// Why: shared by the native-deps install specs so each file stays under the max-lines cap; the +// vi.mock of ./ssh-relay-deploy-helpers in the importing spec is hoisted, so execCommand is mocked here too. +import { EventEmitter } from 'node:events' +import { vi } from 'vitest' + +import { execCommand } from './ssh-relay-deploy-helpers' +import type { SshConnection } from './ssh-connection' + +export type SftpWriteCapture = { + paths: string[] + contents: Record + // execCommand call count observed when ws.end() ran, per path — pins "package.json written before npm install". + execCallCountAtWrite: Record +} + +type SftpCallback = (err: Error | null, resolved?: string) => void +const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 }) + +export function makeMockConnection(capture: SftpWriteCapture): SshConnection { + // Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter. + const sftpCreate = (): unknown => { + const sftp = new EventEmitter() + return Object.assign(sftp, { + mkdir: vi.fn((_p: string, cb: SftpCallback) => cb(null)), + // This host's shell home and SFTP start directory agree, so no namespace redirect is possible. + realpath: vi.fn((_p: string, cb: SftpCallback) => cb(null, '/home/u')), + lstat: vi.fn((_p: string, cb: SftpCallback) => cb(NO_SUCH_SFTP_FILE)), + createWriteStream: vi.fn().mockImplementation((path: string) => { + capture.paths.push(path) + const ws = new EventEmitter() + return Object.assign(ws, { + end: vi.fn((data?: string) => { + capture.contents[path] = `${capture.contents[path] ?? ''}${data ?? ''}` + capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length + setTimeout(() => ws.emit('close'), 0) + }) + }) + }), + end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) + }) + } + return { + canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), + exec: vi.fn().mockResolvedValue({ + on: vi.fn(), + stderr: { on: vi.fn() }, + stdin: {}, + stdout: { on: vi.fn() }, + close: vi.fn() + }), + sftp: vi.fn().mockImplementation(() => Promise.resolve(sftpCreate())) + } as unknown as SshConnection +} + +export type ExecResponse = string | { reject: string } + +// Repair reconnect (isRelayAlreadyInstalled → true) where BOTH native deps are broken and the host +// cannot compile node-pty, so the caller's resets must survive into the node-pty-less reinstall. +export function makeRepairToolchainSkipExecResponses(): ExecResponse[] { + const bothMissing = 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING' + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + bothMissing, // health probe before lock + bothMissing, // re-probe under the repair lock + '', // SFTP-namespace install-owner marker (repair) + { reject: 'gyp ERR! stack Error: not found: make' }, + 'PKG apk', // toolchain probe: no HAVE lines + '', // reset both deps + reinstall without node-pty + 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING\n', // watcher probe: only node-pty still absent + '', // cat probe stderr + '', // rm -f probe stderr + 'DEAD', + 'READY' + ] +} + +export function decodePowerShellCommand(command: string): string | null { + const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) + return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null +} + +// Happy-path exec order: uname, $HOME, mkdir, chmod node, npm install, chmod prebuilds, probe, [cat stderr + rm if MISSING], [rebuild → chmod → re-probe if MISSING], DEAD, READY. +// When the probe rejects (SSH channel close or vanished install dir), the catch skips both stderr-capture and the rm. +// A failed npm install takes one of the two early branches below instead, which never reach `probe`. +export function makeExecResponses(opts: { + npmInstall: 'ok' | { reject: string } + // Only consumed when npmInstall is 'ok'; defaults to a healthy install. + // 'ok' : probe resolves with the sentinel; rm runs once + // 'missing' : probe resolves with 'MISSING'; cat stderr + rm both run + // 'dir-gone': probe rejects (cd-failure), exec rejects directly + // { reject }: probe rejects with custom error (e.g. SSH channel) + probe?: 'ok' | 'missing' | 'dir-gone' | { reject: string } + // Override probe stdout entirely for shell-noise/pollution-prefix pressure tests. + probeStdoutOverride?: string + // Result after the automatic rebuild; defaults to missing so legacy tests still exercise the degraded-mode warning. + repairProbe?: 'ok' | 'missing' + // Raw stdout for the toolchain probe in installNativeDeps' catch; defaults to a full toolchain so the original npm error propagates unchanged. + toolchainProbe?: string + // Result of the node-pty-less reinstall the catch attempts when the toolchain is missing. + // Omit for hosts that never reach it (full toolchain, or a non-build npm failure). + nodePtySkipRetry?: 'ok' | { reject: string } + // Whether @parcel/watcher loads on the skip path; node-pty is always absent there by construction. + nodePtySkipWatcher?: 'ok' | 'missing' +}): ExecResponse[] { + // A failed npm install aborts after the catch probes the toolchain, unless the node-pty-less + // reinstall succeeds; only then are the chmod/probe/launch slots reached. + if (opts.npmInstall !== 'ok' && opts.nodePtySkipRetry !== 'ok') { + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + opts.npmInstall, // npm install rejects + opts.toolchainProbe ?? 'HAVE make\nHAVE g++\nHAVE cc\nHAVE python3\nPKG apt-get', + ...(opts.nodePtySkipRetry ? [opts.nodePtySkipRetry] : []) // reinstall also rejects + ] + } + if (opts.npmInstall !== 'ok') { + // Skip path, exactly as production runs it: no chmod-prebuilds (node-pty is gone) and no rebuild + // (it provably can't compile here). The probe still runs to catch a dead @parcel/watcher. + return [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + opts.npmInstall, // npm install rejects on the missing compiler + opts.toolchainProbe ?? 'HAVE python3\nPKG dnf', + '', // rm -rf node-pty + reinstall without it + // node-pty is always reported missing here; the probe never resolves OK, so cat + rm both run. + opts.nodePtySkipWatcher === 'missing' + ? 'ORCA-NATIVE-DEPS-MISSING:node-pty,@parcel/watcher\nMISSING\n' + : 'ORCA-NATIVE-DEPS-MISSING:node-pty\nMISSING\n', + '', // cat probe stderr + '', // rm -f probe stderr + 'DEAD', + 'READY' + ] + } + const probe = opts.probe ?? 'ok' + const probeSlot: ExecResponse = + opts.probeStdoutOverride !== undefined + ? opts.probeStdoutOverride + : probe === 'ok' + ? 'ORCA-NPTY-PROBE-OK\n' + : probe === 'missing' + ? 'MISSING\n' // shell-level `|| echo MISSING` after require throw + : probe === 'dir-gone' + ? { reject: 'cd: no such file or directory' } + : probe + const slots: ExecResponse[] = [ + '__ORCA_REMOTE_PLATFORM__ Linux x86_64', + '/home/u', + '', // mkdir remoteDir (uploadRelay) + '', // chmod +x node + '', // npm install native deps + '', // chmod prebuilds + probeSlot + ] + // Cleanup execs only run when the probe resolved (not when it rejected). + const probeResolved = typeof probeSlot === 'string' + if (probeResolved) { + const probeOk = probeSlot.includes('ORCA-NPTY-PROBE-OK') + if (!probeOk) { + slots.push('') // cat stderr (graceful failure path captures detail) + } + slots.push('') // rm -f stderr (best-effort cleanup) + if (!probeOk) { + slots.push('') // npm rebuild with lifecycle scripts explicitly enabled + slots.push('') // chmod prebuilds after rebuild + const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' + slots.push(repairProbe) + if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) { + slots.push('') // cat stderr after unsuccessful rebuild + } + slots.push('') // rm -f stderr after rebuild probe + } + } + slots.push('DEAD', 'READY') + return slots +} diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index ceab584dfed..2d98dfa95e0 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -1,6 +1,5 @@ // Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent. -import { EventEmitter } from 'node:events' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => ({ @@ -77,129 +76,14 @@ import { } from './ssh-relay-versioned-install' import { acquireInstallLock } from './ssh-relay-install-lock' import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock' -import type { SshConnection } from './ssh-connection' - -type SftpWriteCapture = { - paths: string[] - contents: Record - // execCommand call count observed when ws.end() ran, per path — pins "package.json written before npm install". - execCallCountAtWrite: Record -} - -type SftpCallback = (err: Error | null, resolved?: string) => void -const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 }) - -function makeMockConnection(capture: SftpWriteCapture): SshConnection { - // Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter. - const sftpCreate = (): unknown => { - const sftp = new EventEmitter() - return Object.assign(sftp, { - mkdir: vi.fn((_p: string, cb: SftpCallback) => cb(null)), - // This host's shell home and SFTP start directory agree, so no namespace redirect is possible. - realpath: vi.fn((_p: string, cb: SftpCallback) => cb(null, '/home/u')), - lstat: vi.fn((_p: string, cb: SftpCallback) => cb(NO_SUCH_SFTP_FILE)), - createWriteStream: vi.fn().mockImplementation((path: string) => { - capture.paths.push(path) - const ws = new EventEmitter() - return Object.assign(ws, { - end: vi.fn((data?: string) => { - capture.contents[path] = `${capture.contents[path] ?? ''}${data ?? ''}` - capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length - setTimeout(() => ws.emit('close'), 0) - }) - }) - }), - end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0)) - }) - } - return { - canRunConcurrentExecCommands: vi.fn().mockReturnValue(false), - exec: vi.fn().mockResolvedValue({ - on: vi.fn(), - stderr: { on: vi.fn() }, - stdin: {}, - stdout: { on: vi.fn() }, - close: vi.fn() - }), - sftp: vi.fn().mockImplementation(() => Promise.resolve(sftpCreate())) - } as unknown as SshConnection -} - -type ExecResponse = string | { reject: string } - -function decodePowerShellCommand(command: string): string | null { - const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/) - return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null -} - -// Happy-path exec order: uname, $HOME, mkdir, chmod node, npm install, chmod prebuilds, probe, [cat stderr + rm if MISSING], [rebuild → chmod → re-probe if MISSING], DEAD, READY. -// When the probe rejects (SSH channel close or vanished install dir), the catch skips both stderr-capture and the rm. -function makeExecResponses(opts: { - npmInstall: 'ok' | { reject: string } - // 'ok' : probe resolves with the sentinel; rm runs once - // 'missing' : probe resolves with 'MISSING'; cat stderr + rm both run - // 'dir-gone': probe rejects (cd-failure), exec rejects directly - // { reject }: probe rejects with custom error (e.g. SSH channel) - probe: 'ok' | 'missing' | 'dir-gone' | { reject: string } - // Override probe stdout entirely for shell-noise/pollution-prefix pressure tests. - probeStdoutOverride?: string - // Result after the automatic rebuild; defaults to missing so legacy tests still exercise the degraded-mode warning. - repairProbe?: 'ok' | 'missing' - // Raw stdout for the toolchain probe in installNativeDeps' catch; defaults to a full toolchain so the original npm error propagates unchanged. - toolchainProbe?: string -}): ExecResponse[] { - // npm install failure aborts after the catch probes the toolchain; no chmod/probe/launch slots are reached. - if (opts.npmInstall !== 'ok') { - return [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir (uploadRelay) - '', // chmod +x node - opts.npmInstall, // npm install rejects - opts.toolchainProbe ?? 'HAVE make\nHAVE g++\nHAVE cc\nHAVE python3\nPKG apt-get' - ] - } - const probeSlot: ExecResponse = - opts.probeStdoutOverride !== undefined - ? opts.probeStdoutOverride - : opts.probe === 'ok' - ? 'ORCA-NPTY-PROBE-OK\n' - : opts.probe === 'missing' - ? 'MISSING\n' // shell-level `|| echo MISSING` after require throw - : opts.probe === 'dir-gone' - ? { reject: 'cd: no such file or directory' } - : opts.probe - const slots: ExecResponse[] = [ - '__ORCA_REMOTE_PLATFORM__ Linux x86_64', - '/home/u', - '', // mkdir remoteDir (uploadRelay) - '', // chmod +x node - opts.npmInstall === 'ok' ? '' : opts.npmInstall, - '', // chmod prebuilds - probeSlot - ] - // Cleanup execs only run when the probe resolved (not when it rejected). - const probeResolved = typeof probeSlot === 'string' - if (probeResolved) { - const probeOk = probeSlot.includes('ORCA-NPTY-PROBE-OK') - if (!probeOk) { - slots.push('') // cat stderr (graceful failure path captures detail) - } - slots.push('') // rm -f stderr (best-effort cleanup) - if (!probeOk) { - slots.push('') // npm rebuild with lifecycle scripts explicitly enabled - slots.push('') // chmod prebuilds after rebuild - const repairProbe = opts.repairProbe === 'ok' ? 'ORCA-NPTY-PROBE-OK\n' : 'MISSING\n' - slots.push(repairProbe) - if (!repairProbe.includes('ORCA-NPTY-PROBE-OK')) { - slots.push('') // cat stderr after unsuccessful rebuild - } - slots.push('') // rm -f stderr after rebuild probe - } - } - slots.push('DEAD', 'READY') - return slots -} +import { + decodePowerShellCommand, + makeExecResponses, + makeMockConnection, + makeRepairToolchainSkipExecResponses, + type ExecResponse, + type SftpWriteCapture +} from './ssh-relay-native-deps-install-fixture' describe('installNativeDeps (via deployAndLaunchRelay)', () => { let warnSpy: ReturnType @@ -294,17 +178,93 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(warnMessages.some((m) => m.includes('[ssh-relay][NATIVE-DEPS-INSTALL-FAIL]'))).toBe(true) }) - it('rewrites the npm failure into an actionable build-tools message when the remote toolchain is missing', async () => { + it('connects without node-pty when the remote toolchain is missing, instead of failing the host', async () => { const conn = makeMockConnection(sftpCapture) feed( makeExecResponses({ npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, - probe: 'ok', - // No HAVE lines + apk present: the tailored hint must come from the remote probe, not a hardcoded apt fallback. + nodePtySkipRetry: 'ok', toolchainProbe: 'PKG apk' }) ) + // node-pty only backs terminals, so a host that cannot compile it still gets files and git. + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + expect(vi.mocked(finalizeInstall)).toHaveBeenCalled() + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const reinstall = execCalls.findLast((c) => c.includes('npm install')) ?? '' + expect(reinstall).toContain('@parcel/watcher@') + expect(reinstall).not.toContain('node-pty@') + // The skip path must stop here: rebuilding is pointless on a host with no compiler. + expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) + // node-pty is legitimately absent, so its probe result must not raise the degraded-mode alarm. + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED]'))).toBe( + false + ) + // The rewritten manifest must drop node-pty too, or npm reconciles it back and rebuilds. + const pkgPath = sftpCapture.paths.findLast((p) => p.endsWith('/package.json')) as string + // The capture concatenates every write to a path; the rewrite is the last manifest line. + const latest = sftpCapture.contents[pkgPath].trim().split('\n').at(-1) as string + const deps = (JSON.parse(latest) as { dependencies: object }).dependencies + expect(deps).toHaveProperty('@parcel/watcher') + expect(deps).not.toHaveProperty('node-pty') + }) + + it('warns when @parcel/watcher is also unloadable after node-pty was skipped', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, + nodePtySkipRetry: 'ok', + nodePtySkipWatcher: 'missing' + }) + ) + + // Watcher failure (e.g. glibc below the floor) is non-fatal, but silent dead file watching is not acceptable. + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) + expect(warnMessages.some((m) => m.includes('[ssh-relay][WATCHER-MISSING-NPTY-SKIPPED]'))).toBe( + true + ) + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect(execCalls.some((c) => c.includes('npm rebuild'))).toBe(false) + }) + + it('hard-fails on a gyp error when the remote toolchain is actually complete', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, + // Probe contradicts the gyp output: the tools are all there, so the real cause is unknown. + toolchainProbe: 'HAVE make\nHAVE g++\nHAVE python3\nPKG apt-get' + }) + ) + + const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) + // Degrading here would silently drop terminals on a host that can build them. + expect((error as Error).message).toContain('gyp ERR!') + expect((error as Error).message).not.toContain('build tools') + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + expect(execCalls.filter((c) => c.includes('npm install'))).toHaveLength(1) + expect(execCalls.some((c) => c.includes("rm -rf 'node_modules/node-pty'"))).toBe(false) + expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() + }) + + it('still reports the actionable build-tools error when the node-pty-less retry also fails', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: { reject: 'gyp ERR! stack Error: not found: make' }, + // No HAVE lines + apk present: the tailored hint must come from the remote probe, not a hardcoded apt fallback. + toolchainProbe: 'PKG apk', + nodePtySkipRetry: { reject: 'npm ERR! registry unreachable' } + }) + ) + const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) expect(error).toBeInstanceOf(Error) const message = (error as Error).message @@ -314,10 +274,14 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(message).toContain('sudo apk add build-base python3') // The raw npm/node-gyp output is preserved for triage, not discarded. expect(message).toContain('not found: make') - - const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + // The retry's own cause is unrelated to the toolchain, so it must survive as cause + a log line. + expect((error as Error).cause).toBeInstanceOf(Error) + expect(((error as Error).cause as Error).message).toContain('registry unreachable') + const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? '')) expect( - execCalls.some((c) => c.includes('command -v "$t"') && c.includes('command -v "$p"')) + warnMessages.some( + (m) => m.includes('[ssh-relay][NPTY-SKIP-RETRY-FAIL]') && m.includes('registry unreachable') + ) ).toBe(true) expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled() }) @@ -719,6 +683,23 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => { expect(installCommand).not.toContain("rm -rf 'node_modules/node-pty'") }) + it('keeps the caller resets when a repair reconnect has to drop node-pty', async () => { + vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) + const conn = makeMockConnection(sftpCapture) + feed(makeRepairToolchainSkipExecResponses()) + + await expect(deployAndLaunchRelay(conn)).resolves.toBeDefined() + expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1) + + const execCalls = vi.mocked(execCommand).mock.calls.map(([, c]) => c) + const reinstall = execCalls.findLast((c) => c.includes('npm install')) ?? '' + expect(reinstall).not.toContain('node-pty@') + expect(reinstall).toContain("rm -rf 'node_modules/node-pty'") + // Dropping the repair's own resets here leaves npm calling the broken watcher up to date. + expect(reinstall).toContain("rm -rf 'node_modules/@parcel/watcher'") + expect(reinstall).toContain("-name 'watcher-*'") + }) + it('launches an already-installed relay in degraded mode when repair throws', async () => { // Why: a repair failure on a completed dir must not block the connection — relay still serves fs/git/preflight; next reconnect retries. vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true) diff --git a/src/main/ssh/ssh-remote-cli-error-response.ts b/src/main/ssh/ssh-remote-cli-error-response.ts new file mode 100644 index 00000000000..dacf9402a45 --- /dev/null +++ b/src/main/ssh/ssh-remote-cli-error-response.ts @@ -0,0 +1,10 @@ +import type { RpcResponse } from '../runtime/rpc/core' + +export function buildRemoteCliError(message: string, code = 'runtime_error'): RpcResponse { + return { + id: 'remote-cli-local', + ok: false, + error: { code, message }, + _meta: { runtimeId: 'unknown' } + } +} diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 3d1c3e60793..6c9ce09244b 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -79,7 +79,8 @@ describe('runRemoteOrcaCli', () => { message.read_at = new Date(0).toISOString() } } - }) + }), + getActiveDispatchForIdentity: vi.fn(() => undefined) } const runtime = { getRuntimeId: () => 'runtime-test', @@ -155,7 +156,7 @@ describe('runRemoteOrcaCli', () => { expect(db.getUnreadMessages('term_windows')[0]?.from_handle).toBe('term_ssh') }) - it('forwards remote pane identity through the legacy orchestration fallback', async () => { + it('does not trust caller-supplied remote pane identity in the legacy fallback', async () => { const { runtime, db } = createRuntime() const result = await runRemoteOrcaCli( @@ -173,7 +174,7 @@ describe('runRemoteOrcaCli', () => { expect(result.exitCode).toBe(0) expect(db.insertMessage).toHaveBeenCalledWith( - expect.objectContaining({ senderPaneKey: 'tab_ssh:leaf_ssh' }) + expect.objectContaining({ senderPaneKey: undefined }) ) }) @@ -183,8 +184,14 @@ describe('runRemoteOrcaCli', () => { runtime.setOrchestrationDb(db) vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - const task = db.createTask({ spec: 'remote work' }) + const run = db.createRun({ + objective: 'Remote lifecycle rejection', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_foreign:leaf_foreign') try { const result = await runRemoteOrcaCli( @@ -202,7 +209,11 @@ describe('runRemoteOrcaCli', () => { '--type', 'worker_done', '--payload', - JSON.stringify({ taskId: task.id, dispatchId: dispatch.id }), + JSON.stringify({ + taskId: task.id, + dispatchId: dispatch.id, + outcome: 'succeeded' + }), '--json' ], cwd: '/home/alice/repo', @@ -231,8 +242,14 @@ describe('runRemoteOrcaCli', () => { runtime.setOrchestrationDb(db) vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) - const task = db.createTask({ spec: 'remote work' }) + const run = db.createRun({ + objective: 'Remote lifecycle success', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) const dispatch = db.createDispatchContext(task.id, 'term_ssh', 'tab_owner:leaf_owner') + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_owner:leaf_owner') try { const result = await runRemoteOrcaCli( @@ -251,6 +268,8 @@ describe('runRemoteOrcaCli', () => { task.id, '--dispatch-id', dispatch.id, + '--outcome', + 'succeeded', '--files-modified', 'src/a.ts, src/b.ts', '--json' @@ -274,6 +293,70 @@ describe('runRemoteOrcaCli', () => { } }) + it('carries the Dispatch capability through the SSH envelope', async () => { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {}) + vi.spyOn(runtime, 'notifyMessageArrived').mockImplementation(() => {}) + vi.spyOn(runtime, 'getTerminalPaneKey').mockReturnValue('tab_ssh:leaf_ssh') + vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockReturnValue('ssh_runtime:pty:1') + const run = db.createRun({ + objective: 'SSH capability transport', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:leaf_coord' + }) + const task = db.createTask({ spec: 'remote work', runId: run.id }) + const started = db.createStartingWorkerDispatch({ taskId: task.id, startOptions: {} }) + const capability = db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle: 'term_ssh', + paneKey: 'tab_ssh:leaf_ssh', + processIncarnation: 'ssh_runtime:pty:1', + worktreeId: 'repo::/home/alice/repo', + setupState: 'not_applicable', + effects: [] + }) + db.markWorkerDispatchReady(started.dispatch.id) + + try { + const result = await runRemoteOrcaCli( + runtime, + { + argv: [ + 'orchestration', + 'send', + '--type', + 'worker_done', + '--subject', + 'Done', + '--task-id', + task.id, + '--dispatch-id', + started.dispatch.id, + '--outcome', + 'succeeded', + '--dispatch-capability', + capability, + '--json' + ], + cwd: '/home/alice/repo', + env: { + ORCA_TERMINAL_HANDLE: 'term_ssh', + ORCA_PANE_KEY: 'tab_ssh:leaf_ssh' + } + }, + LEGACY_FALLBACK_OPTIONS + ) + + expect(result.exitCode).toBe(0) + expect(db.getTask(task.id)).toMatchObject({ status: 'completed' }) + expect(db.getWorkerDispatch(started.dispatch.id)).toMatchObject({ state: 'succeeded' }) + } finally { + db.close() + } + }) + it('rejects identity-less lifecycle sends in the legacy fallback', async () => { const { runtime, db } = createRuntime() diff --git a/src/main/ssh/ssh-remote-orca-cli.ts b/src/main/ssh/ssh-remote-orca-cli.ts index 14f5448d3d0..70c25943616 100644 --- a/src/main/ssh/ssh-remote-orca-cli.ts +++ b/src/main/ssh/ssh-remote-orca-cli.ts @@ -1,4 +1,6 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types' +import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import { RpcDispatcher } from '../runtime/rpc/dispatcher' import type { RpcResponse } from '../runtime/rpc/core' import type { OrcaRuntimeService } from '../runtime/orca-runtime' @@ -18,6 +20,7 @@ import { requiredRemoteCliString, resolveRemoteCliHandle } from './ssh-remote-cli-args' +import { buildRemoteCliError } from './ssh-remote-cli-error-response' import { getRemoteLinearHelp, tryDispatchRemoteLinearCli } from './ssh-remote-linear-cli' import { getRemoteOrchestrationPayload, @@ -51,7 +54,7 @@ export async function runRemoteOrcaCli( if (interactiveMessage) { if (json) { return { - stdout: `${JSON.stringify(buildLocalError(interactiveMessage, 'unsupported_over_ssh'), null, 2)}\n`, + stdout: `${JSON.stringify(buildRemoteCliError(interactiveMessage, 'unsupported_over_ssh'), null, 2)}\n`, stderr: '', exitCode: 1 } @@ -117,7 +120,7 @@ async function runLegacyRemoteOrcaCli( : 'runtime_error' if (json) { return { - stdout: `${JSON.stringify(buildLocalError(message, code), null, 2)}\n`, + stdout: `${JSON.stringify(buildRemoteCliError(message, code), null, 2)}\n`, stderr: '', exitCode: 1 } @@ -167,19 +170,27 @@ async function dispatchRemoteCli( }) case 'orchestration send': { const type = optionalRemoteCliString(parsed.flags, 'type') - return await call(dispatcher, 'orchestration.send', { - from: resolveRemoteOrchestrationSender(parsed.flags, env, type), - to: requiredRemoteCliString(parsed.flags, 'to'), - subject: requiredRemoteCliString(parsed.flags, 'subject'), - body: optionalRemoteCliString(parsed.flags, 'body'), - type, - priority: optionalRemoteCliString(parsed.flags, 'priority'), - threadId: optionalRemoteCliString(parsed.flags, 'thread-id'), - payload: getRemoteOrchestrationPayload(parsed.flags), - // Why: the legacy in-process bridge must preserve the same pane - // authority as the full host CLI passthrough. - senderPaneKey: env.ORCA_PANE_KEY || undefined - }) + return await call( + dispatcher, + 'orchestration.send', + { + from: resolveRemoteOrchestrationSender(parsed.flags, env, type), + to: optionalRemoteCliString(parsed.flags, 'to'), + subject: requiredRemoteCliString(parsed.flags, 'subject'), + body: optionalRemoteCliString(parsed.flags, 'body'), + type, + priority: optionalRemoteCliString(parsed.flags, 'priority'), + threadId: optionalRemoteCliString(parsed.flags, 'thread-id'), + payload: getRemoteOrchestrationPayload(parsed.flags), + // Why: the legacy in-process bridge must preserve the same pane + // authority as the full host CLI passthrough. + senderPaneKey: env.ORCA_PANE_KEY || undefined + }, + { + orchestrationCapability: optionalRemoteCliString(parsed.flags, 'dispatch-capability'), + orchestrationRequestId: optionalRemoteCliString(parsed.flags, 'retry-request') + } + ) } case 'orchestration check': return await call(dispatcher, 'orchestration.check', { @@ -215,21 +226,18 @@ async function dispatchRemoteCli( async function call( dispatcher: RpcDispatcher, method: string, - params?: Record + params?: Record, + envelope?: RuntimeOrchestrationEnvelope ): Promise { return await dispatcher.dispatch({ id: `remote-cli-${Date.now()}`, authToken: 'remote-cli', method, - params + params, + orchestrationCapability: envelope?.orchestrationCapability, + orchestrationContractVersion: method.startsWith('orchestration.') + ? ORCHESTRATION_CONTRACT_VERSION + : undefined, + orchestrationRequestId: envelope?.orchestrationRequestId }) } - -function buildLocalError(message: string, code = 'runtime_error'): RpcResponse { - return { - id: 'remote-cli-local', - ok: false, - error: { code, message }, - _meta: { runtimeId: 'unknown' } - } -} diff --git a/src/main/ssh/ssh-remote-orchestration-send.test.ts b/src/main/ssh/ssh-remote-orchestration-send.test.ts index 6efd9a18891..79ed89ddef0 100644 --- a/src/main/ssh/ssh-remote-orchestration-send.test.ts +++ b/src/main/ssh/ssh-remote-orchestration-send.test.ts @@ -38,6 +38,7 @@ describe('remote orchestration send compatibility', () => { new Map([ ['task-id', 'task_1'], ['dispatch-id', 'ctx_1'], + ['outcome', 'succeeded'], ['files-modified', 'src/a.ts, src/b.ts,'], ['report-path', 'report.md'], ['phase', 'reviewing'] @@ -47,6 +48,7 @@ describe('remote orchestration send compatibility', () => { expect(JSON.parse(payload ?? '{}')).toEqual({ taskId: 'task_1', dispatchId: 'ctx_1', + outcome: 'succeeded', filesModified: ['src/a.ts', 'src/b.ts'], reportPath: 'report.md', phase: 'reviewing' diff --git a/src/main/ssh/ssh-remote-orchestration-send.ts b/src/main/ssh/ssh-remote-orchestration-send.ts index 5e844a9c586..391be6499df 100644 --- a/src/main/ssh/ssh-remote-orchestration-send.ts +++ b/src/main/ssh/ssh-remote-orchestration-send.ts @@ -37,10 +37,11 @@ export function getRemoteOrchestrationPayload(flags: RemoteFlags): string | unde const rawPayload = optionalString(flags, 'payload') const taskId = optionalString(flags, 'task-id') const dispatchId = optionalString(flags, 'dispatch-id') + const outcome = optionalString(flags, 'outcome') const filesModified = optionalString(flags, 'files-modified') const reportPath = optionalString(flags, 'report-path') const phase = optionalString(flags, 'phase') - const hasStructuredPayload = [taskId, dispatchId, filesModified, reportPath, phase].some( + const hasStructuredPayload = [taskId, dispatchId, outcome, filesModified, reportPath, phase].some( (value) => value !== undefined ) if (!hasStructuredPayload) { @@ -62,6 +63,15 @@ export function getRemoteOrchestrationPayload(flags: RemoteFlags): string | unde if (dispatchId) { payload.dispatchId = dispatchId } + if (outcome) { + if (outcome !== 'succeeded' && outcome !== 'failed') { + throw new RemoteCliArgumentError( + 'invalid_argument', + 'Invalid --outcome. Expected succeeded or failed.' + ) + } + payload.outcome = outcome + } if (filesModified) { payload.filesModified = filesModified .split(',') diff --git a/src/main/startup/configure-process.test.ts b/src/main/startup/configure-process.test.ts index 55f4d59b3fc..512467eb724 100644 --- a/src/main/startup/configure-process.test.ts +++ b/src/main/startup/configure-process.test.ts @@ -398,21 +398,6 @@ describe('enableMainProcessGpuFeatures', () => { expect(app.commandLine.appendSwitch).toHaveBeenCalledWith('max-active-webgl-contexts', '128') }) - it('configures the shared WebGL budget independently for GPU fallback', async () => { - const { app } = await import('electron') - const { TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS } = - await import('../../shared/terminal-webgl-context-budget') - const { configureMainProcessWebglContextBudget } = await import('./configure-process') - - vi.mocked(app.commandLine.appendSwitch).mockClear() - configureMainProcessWebglContextBudget() - - expect(app.commandLine.appendSwitch).toHaveBeenCalledWith( - 'max-active-webgl-contexts', - String(TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS) - ) - }) - it('disables Skia Graphite only on macOS without disabling hardware acceleration', async () => { const { app } = await import('electron') const { enableMainProcessGpuFeatures } = await import('./configure-process') diff --git a/src/main/startup/configure-process.ts b/src/main/startup/configure-process.ts index 8d4b51b5b8d..292f6195258 100644 --- a/src/main/startup/configure-process.ts +++ b/src/main/startup/configure-process.ts @@ -2,7 +2,6 @@ import { app } from 'electron' import { existsSync, mkdirSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join, resolve } from 'node:path' -import { TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS } from '../../shared/terminal-webgl-context-budget' import { getVersionManagerBinPaths } from '../codex-cli/command' import { getMainE2EConfig } from '../e2e-config' @@ -255,13 +254,6 @@ export function installDevParentSignalQuit(isDev: boolean): void { process.once('SIGTERM', onSignal) } -export function configureMainProcessWebglContextBudget(): void { - app.commandLine.appendSwitch( - 'max-active-webgl-contexts', - String(TERMINAL_WEBGL_MAX_ACTIVE_CONTEXTS) - ) -} - export function enableMainProcessGpuFeatures(): void { if (process.platform === 'linux' && getMainE2EConfig().userDataDir) { // Why: Ubuntu/Xvfb runners fail Electron startup with "GPU process isn't usable"; E2E needs no GPU, so use the software path. @@ -279,7 +271,7 @@ export function enableMainProcessGpuFeatures(): void { // Why: Blink evicts the oldest WebGL context past 16/renderer and each terminal pane holds one, silently downgrading panes to DOM. // 128 raises the ceiling for real layouts while staying bounded so context leaks still surface. - configureMainProcessWebglContextBudget() + app.commandLine.appendSwitch('max-active-webgl-contexts', '128') const ozonePlatform = (app.commandLine.getSwitchValue('ozone-platform') ?? '').toLowerCase() const ozonePlatformHint = (process.env.ELECTRON_OZONE_PLATFORM_HINT ?? '').toLowerCase() diff --git a/src/main/updater-events.ts b/src/main/updater-events.ts index 5164161854e..411bb1a7184 100644 --- a/src/main/updater-events.ts +++ b/src/main/updater-events.ts @@ -30,7 +30,8 @@ type UpdaterHandlerContext = { getUserInitiatedCheck: () => boolean handleQuitAndInstallFailure: () => boolean isQuitAndInstallHandoffActive: () => boolean - hasNewerDownloadedVersion: () => boolean + hasInstallableDownloadedVersion: () => boolean + isLocalBuildCheck: () => boolean shouldHandleUpdaterErrorEvent: () => boolean clearUpdateAvailableEventPending: (attemptId: number | null) => void isActiveUpdateCheckAttempt: (attemptId: number) => boolean @@ -40,6 +41,7 @@ type UpdaterHandlerContext = { performQuitAndInstall: () => void | Promise shouldDeferMacQuitForInstall: () => boolean recordCompletedUpdateCheck: () => void + restoreReleaseUpdateSource: () => void sendCheckFailureStatus: ( message: string, userInitiated?: boolean, @@ -70,7 +72,8 @@ export function registerAutoUpdaterHandlers({ getUserInitiatedCheck, handleQuitAndInstallFailure, isQuitAndInstallHandoffActive, - hasNewerDownloadedVersion, + hasInstallableDownloadedVersion, + isLocalBuildCheck, shouldHandleUpdaterErrorEvent, clearUpdateAvailableEventPending, isActiveUpdateCheckAttempt, @@ -80,6 +83,7 @@ export function registerAutoUpdaterHandlers({ performQuitAndInstall, shouldDeferMacQuitForInstall, recordCompletedUpdateCheck, + restoreReleaseUpdateSource, sendCheckFailureStatus, sendErrorStatus, sendStatus, @@ -93,9 +97,9 @@ export function registerAutoUpdaterHandlers({ // Why: electron-updater fires 'update-downloaded' before Squirrel.Mac finishes; track readiness to avoid a premature "ready". if (process.platform === 'darwin') { nativeUpdater.on('update-downloaded', () => { - const hasNewerVersion = hasNewerDownloadedVersion() - handleMacInstallerReady(hasNewerVersion, performQuitAndInstall, () => { - // Send the held 'downloaded' status now, only if the staged version is newer. + const hasInstallableVersion = hasInstallableDownloadedVersion() + handleMacInstallerReady(hasInstallableVersion, performQuitAndInstall, () => { + // Send the held status only while its staged build is still installable. sendStatus({ state: 'downloaded', version: getPendingInstallVersion(), @@ -121,7 +125,7 @@ export function registerAutoUpdaterHandlers({ if ( deferMacQuitUntilInstallerReady( getCurrentStatus(), - hasNewerDownloadedVersion(), + hasInstallableDownloadedVersion(), getPendingInstallVersion, sendStatus ) @@ -158,8 +162,8 @@ export function registerAutoUpdaterHandlers({ const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() setUserInitiatedCheck(false) - // Guard: don't show an update that isn't actually newer than what's running. - if (compareVersions(info.version, app.getVersion()) <= 0) { + // Release checks remain newer-only; validated local builds may intentionally downgrade. + if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { clearAvailableUpdateContext() if (missingManifestFallback || publishingWindowLastGoodCheck) { // Why: a current-version fallback manifest means the primary is transiently missing; keep the short retry cadence. @@ -178,7 +182,9 @@ export function registerAutoUpdaterHandlers({ markUpdateAvailableEventPending(attemptId) void (async () => { try { - const changelog = await fetchChangelog(info.version, app.getVersion()).catch(() => null) + const changelog = isLocalBuildCheck() + ? null + : await fetchChangelog(info.version, app.getVersion()).catch(() => null) // Why: async fetch may take seconds; bail if a newer event superseded this attempt to avoid a stale 'available' broadcast. if (!isActiveUpdateCheckAttempt(attemptId)) { @@ -191,13 +197,15 @@ export function registerAutoUpdaterHandlers({ // Why: side effects must run after the guard so a concurrent 'error' during the fetch can't leave orphaned state. setAvailableVersion(info.version) setAvailableReleaseUrl(null) - if (missingManifestFallback || publishingWindowLastGoodCheck) { - // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } else { - recordCompletedUpdateCheck() - if (!wasUserInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + if (!isLocalBuildCheck()) { + if (missingManifestFallback || publishingWindowLastGoodCheck) { + // Why: last-good release is a temporary fallback; keep probing so users can move to the newest tag once it publishes. + scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } else { + recordCompletedUpdateCheck() + if (!wasUserInitiated) { + scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } } } @@ -217,18 +225,24 @@ export function registerAutoUpdaterHandlers({ const missingManifestFallback = consumeMissingManifestPrereleaseFallbackResult() const publishingWindowLastGoodCheck = getPublishingWindowLastGoodCheck() const wasUserInitiated = missingManifestFallback?.userInitiated ?? getUserInitiatedCheck() + const localBuildCheck = isLocalBuildCheck() setUserInitiatedCheck(false) clearAvailableUpdateContext() - if (missingManifestFallback || publishingWindowLastGoodCheck) { - // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h. - scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) - } else { - recordCompletedUpdateCheck() - if (!wasUserInitiated) { - scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + if (!localBuildCheck) { + if (missingManifestFallback || publishingWindowLastGoodCheck) { + // Why: last-good not-available is a transient release-transition outcome; keep the short retry, don't suppress for 24h. + scheduleAutomaticUpdateCheck(AUTO_UPDATE_RETRY_INTERVAL_MS) + } else { + recordCompletedUpdateCheck() + if (!wasUserInitiated) { + scheduleAutomaticUpdateCheck(AUTO_UPDATE_CHECK_INTERVAL_MS) + } } } sendStatus({ state: 'not-available', userInitiated: wasUserInitiated || undefined }) + if (localBuildCheck) { + restoreReleaseUpdateSource() + } }) autoUpdater.on('download-progress', (progress) => { @@ -242,8 +256,8 @@ export function registerAutoUpdaterHandlers({ autoUpdater.on('update-downloaded', (info) => { clearBackgroundCheckLaunchPending() - // Skip the banner for non-newer versions (same-version or stale cached updates). - if (compareVersions(info.version, app.getVersion()) <= 0) { + // Release downloads remain newer-only; the local source was validated before checking. + if (!isLocalBuildCheck() && compareVersions(info.version, app.getVersion()) <= 0) { clearAvailableUpdateContext() sendStatus({ state: 'not-available' }) return @@ -288,5 +302,8 @@ export function registerAutoUpdaterHandlers({ return } sendErrorStatus(message, wasUserInitiated || undefined) + if (isLocalBuildCheck()) { + restoreReleaseUpdateSource() + } }) } diff --git a/src/main/updater-fallback.ts b/src/main/updater-fallback.ts index 411ddbd0aba..d91cdf75ef2 100644 --- a/src/main/updater-fallback.ts +++ b/src/main/updater-fallback.ts @@ -10,6 +10,9 @@ export const isPrereleaseVersion = isPrereleaseAppVersion export const isValidVersion = isValidAppVersion export function statusesEqual(left: UpdateStatus, right: UpdateStatus): boolean { + if (left.source !== right.source) { + return false + } switch (left.state) { case 'idle': return right.state === 'idle' diff --git a/src/main/updater.test.ts b/src/main/updater.test.ts index b09c727ee8c..730dd5be96c 100644 --- a/src/main/updater.test.ts +++ b/src/main/updater.test.ts @@ -50,6 +50,8 @@ const { autoUpdaterMock.setFeedURL.mockClear() autoUpdaterMock.updateConfigPath = undefined autoUpdaterMock.allowPrerelease = false + autoUpdaterMock.allowDowngrade = false + autoUpdaterMock.disableDifferentialDownload = false autoUpdaterMock.autoRunAppAfterInstall = true delete (autoUpdaterMock as Record).verifyUpdateCodeSignature } @@ -59,6 +61,8 @@ const { autoInstallOnAppQuit: false, autoRunAppAfterInstall: true, allowPrerelease: false, + allowDowngrade: false, + disableDifferentialDownload: false, on, checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), @@ -146,6 +150,14 @@ const { fetchNewerReleaseTagsMock } = vi.hoisted(() => ({ fetchNewerReleaseTagsMock: vi.fn() })) +const { chooseLocalBuildMock, startLocalBuildFeedMock, closeLocalBuildFeedMock } = vi.hoisted( + () => ({ + chooseLocalBuildMock: vi.fn(), + startLocalBuildFeedMock: vi.fn(), + closeLocalBuildFeedMock: vi.fn() + }) +) + vi.mock('./updater-prerelease-feed', () => ({ fetchNewerReleaseTagsWithReadiness: async (...args: unknown[]) => { const result = await fetchNewerReleaseTagsMock(...args) @@ -157,6 +169,14 @@ vi.mock('./updater-prerelease-feed', () => ({ `https://github.com/stablyai/orca/releases/download/${tag}` })) +vi.mock('./local-builds/local-build-switch', () => ({ + chooseLocalBuild: chooseLocalBuildMock +})) + +vi.mock('./local-builds/local-build-feed-server', () => ({ + startLocalBuildFeed: startLocalBuildFeedMock +})) + describe('updater', () => { beforeEach(() => { vi.resetModules() @@ -177,6 +197,12 @@ describe('updater', () => { shouldApplyNudgeMock.mockReset().mockReturnValue(false) fetchChangelogMock.mockReset().mockResolvedValue(null) fetchNewerReleaseTagsMock.mockReset().mockResolvedValue([]) + chooseLocalBuildMock.mockReset() + closeLocalBuildFeedMock.mockReset() + startLocalBuildFeedMock.mockReset().mockResolvedValue({ + url: 'http://127.0.0.1:1234/token/', + close: closeLocalBuildFeedMock + }) vi.unstubAllGlobals() vi.useRealTimers() }) @@ -196,6 +222,184 @@ describe('updater', () => { expect(powerMonitorOnMock).not.toHaveBeenCalled() }) + it.runIf(process.platform === 'darwin')( + 'allows a validated local build to downgrade through the normal updater lifecycle', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockImplementation(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-available', { version: '0.9.0-local.1' }) + return Promise.resolve(undefined) + }) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdatesFromMenu } = await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowDowngrade).toBe(true) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(true) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'http://127.0.0.1:1234/token/' + }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith( + 'updater:status', + expect.objectContaining({ + state: 'available', + version: '0.9.0-local.1', + source: 'local' + }) + ) + }) + + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'http://127.0.0.1:1234/token/' + }) + expect(autoUpdaterMock.allowDowngrade).toBe(true) + + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined) + checkForUpdatesFromMenu() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + } + ) + + it.runIf(process.platform === 'darwin')( + 'restores ordinary release checks after local build selection fails', + async () => { + chooseLocalBuildMock.mockRejectedValue(new Error('invalid local build')) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: 'invalid local build', + userInitiated: true, + source: 'local' + }) + }) + + checkForUpdates() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + + it.runIf(process.platform === 'darwin')( + 'restores ordinary release checks after a local build is unavailable', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockImplementationOnce(() => { + autoUpdaterMock.emit('checking-for-update') + autoUpdaterMock.emit('update-not-available') + return Promise.resolve(undefined) + }) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1) + }) + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'not-available', + userInitiated: true, + source: 'local' + }) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + + checkForUpdates() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + + it.runIf(process.platform === 'darwin')( + 'restores ordinary release checks after a local updater failure', + async () => { + chooseLocalBuildMock.mockResolvedValue({ + version: '0.9.0-local.1', + manifestContent: 'version: 0.9.0-local.1', + artifacts: new Map() + }) + autoUpdaterMock.checkForUpdates.mockRejectedValueOnce(new Error('local feed failed')) + const send = vi.fn() + const { setupAutoUpdater, checkForUpdates, checkForUpdatesFromMenu } = + await import('./updater') + setupAutoUpdater({ webContents: { send } } as never, { + getLastUpdateCheckAt: () => Date.now() + }) + + checkForUpdatesFromMenu({ localBuild: true }) + await vi.waitFor(() => { + expect(send).toHaveBeenCalledWith('updater:status', { + state: 'error', + message: 'local feed failed', + userInitiated: true, + source: 'local' + }) + }) + expect(closeLocalBuildFeedMock).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.allowDowngrade).toBe(false) + expect(autoUpdaterMock.disableDifferentialDownload).toBe(false) + + checkForUpdates() + await vi.waitFor(() => { + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2) + }) + expect(autoUpdaterMock.setFeedURL).toHaveBeenLastCalledWith({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } + ) + it('deduplicates identical check errors from the event and rejected promise', async () => { autoUpdaterMock.checkForUpdates.mockImplementation(() => { autoUpdaterMock.emit('checking-for-update') diff --git a/src/main/updater.ts b/src/main/updater.ts index a1373af8a62..34067e03b50 100644 --- a/src/main/updater.ts +++ b/src/main/updater.ts @@ -44,6 +44,7 @@ import { hasServeUpdateSupervisor, requestServeUpdateHandoff } from './serve-update-handoff' +import type { LocalBuildFeed } from './local-builds/local-build-feed-server' type CheckFailureSource = 'event' | 'promise' | 'fallback-promise' type MissingManifestPrereleaseFallbackResult = { userInitiated: boolean } @@ -128,6 +129,9 @@ let downloadInFlight = false /** Guards the macOS `activate` handler from reopening the old version while ShipIt replaces the .app bundle. */ let quittingForUpdate = false let autoUpdater: ElectronAutoUpdater | null = null +let activeUpdateSource: 'release' | 'local' = 'release' +let activeLocalBuildFeed: LocalBuildFeed | null = null +let localBuildSelectionInProgress = false function getAutoUpdater(): ElectronAutoUpdater { if (!autoUpdater) { @@ -141,6 +145,36 @@ function clearAvailableUpdateContext(): void { availableReleaseUrl = null } +function closeLocalBuildFeed(): void { + const feed = activeLocalBuildFeed + activeLocalBuildFeed = null + if (feed) { + void feed.close() + } +} + +function restoreReleaseUpdateSource(): void { + closeLocalBuildFeed() + activeUpdateSource = 'release' + if (autoUpdater) { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } +} + +function sendLocalBuildErrorAndRestore(message: string, userInitiated?: boolean): void { + clearAvailableUpdateContext() + if ( + currentStatus.state !== 'error' || + currentStatus.message !== message || + currentStatus.userInitiated !== userInitiated || + currentStatus.source !== 'local' + ) { + sendStatus({ state: 'error', message, userInitiated, source: 'local' }) + } + restoreReleaseUpdateSource() +} + function clearPrereleaseFallbackContext(): void { pendingPrereleaseFallback = null } @@ -219,7 +253,9 @@ function sendStatus(status: UpdateStatus): void { } } - const decoratedStatus = decorateStatusWithActiveNudge(status) + const sourcedStatus: UpdateStatus = + activeUpdateSource === 'local' ? { ...status, source: 'local' } : status + const decoratedStatus = decorateStatusWithActiveNudge(sourcedStatus) if (isUpdateCheckResultState(status.state)) { finishActiveUpdateCheckAttempt() @@ -523,8 +559,11 @@ function getKnownReleaseUrl(): string | undefined { return availableReleaseUrl ?? undefined } -function hasNewerDownloadedVersion(): boolean { - return availableVersion !== null && compareVersions(availableVersion, app.getVersion()) > 0 +function hasInstallableDownloadedVersion(): boolean { + return ( + availableVersion !== null && + (activeUpdateSource === 'local' || compareVersions(availableVersion, app.getVersion()) > 0) + ) } function getPendingInstallVersion(): string { @@ -769,6 +808,10 @@ async function sendCheckFailureStatus( source: CheckFailureSource = 'promise', sourceError?: unknown ): Promise { + if (activeUpdateSource === 'local') { + sendLocalBuildErrorAndRestore(message, userInitiated) + return + } const failureKey = getCheckFailureKey(message, userInitiated) if ( source === 'promise' && @@ -1172,6 +1215,9 @@ function retryPrereleaseFallbackAfterMissingManifest( function runBackgroundUpdateCheck( nudgeId: string | null = getPersistedPendingUpdateNudgeId() ): void { + if (activeUpdateSource === 'local' || localBuildSelectionInProgress) { + return + } if (backgroundCheckLaunchPending || currentStatus.state === 'checking') { return } @@ -1238,6 +1284,20 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { sendStatus({ state: 'not-available', userInitiated: true }) return } + if (options?.localBuild) { + void checkForLocalBuildFromMenu() + return + } + if (localBuildSelectionInProgress) { + return + } + if ( + activeUpdateSource === 'local' && + (currentStatus.state === 'checking' || currentStatus.state === 'downloading') + ) { + return + } + restoreReleaseUpdateSource() const checkVariant = getUpdateCheckVariant(options) if (checkVariant === 'prerelease') { @@ -1303,12 +1363,63 @@ export function checkForUpdatesFromMenu(options?: UpdateCheckOptions): void { }) } +async function checkForLocalBuildFromMenu(): Promise { + if (process.platform !== 'darwin') { + sendLocalBuildErrorAndRestore( + 'Local build switching is currently available only on macOS.', + true + ) + return + } + if (currentStatus.state === 'checking' || currentStatus.state === 'downloading') { + return + } + if (localBuildSelectionInProgress) { + return + } + localBuildSelectionInProgress = true + try { + const [{ chooseLocalBuild }, { startLocalBuildFeed }] = await Promise.all([ + import('./local-builds/local-build-switch'), + import('./local-builds/local-build-feed-server') + ]) + const candidate = await chooseLocalBuild(mainWindowRef) + if (!candidate) { + return + } + closeLocalBuildFeed() + const feed = await startLocalBuildFeed(candidate) + activeLocalBuildFeed = feed + activeUpdateSource = 'local' + clearPrereleaseFallbackContext() + clearPublishingWindowLastGoodCheck() + clearAvailableUpdateContext() + activeUpdateNudgeId = null + userInitiatedCheck = true + sendStatus({ state: 'checking', userInitiated: true }) + + const updater = getAutoUpdater() + updater.allowDowngrade = true + updater.disableDifferentialDownload = true + updater.setFeedURL({ provider: 'generic', url: feed.url }) + const attemptId = beginUpdateCheckAttempt() + markUpdateCheckLaunched(attemptId) + await updater.checkForUpdates() + handleSettledUpdateCheckPromise(attemptId) + } catch (error) { + userInitiatedCheck = false + sendLocalBuildErrorAndRestore(String((error as Error)?.message ?? error), true) + } finally { + localBuildSelectionInProgress = false + } +} + export function isQuittingForUpdate(): boolean { return quittingForUpdate } export function quitAndInstall(): void { - if (pendingQuitAndInstallTimer || quitAndInstallInProgress) { + if (localBuildSelectionInProgress || pendingQuitAndInstallTimer || quitAndInstallInProgress) { return } @@ -1319,7 +1430,7 @@ export function quitAndInstall(): void { if ( deferMacQuitUntilInstallerReady( currentStatus, - hasNewerDownloadedVersion(), + hasInstallableDownloadedVersion(), getPendingInstallVersion, sendStatus ) @@ -1441,6 +1552,10 @@ export function setupAutoUpdater( const autoUpdater = getAutoUpdater() autoUpdater.autoDownload = false + if (activeUpdateSource === 'release') { + autoUpdater.allowDowngrade = false + autoUpdater.disableDifferentialDownload = false + } // Why: supervised serve installs require an explicit handoff; ordinary service quits must never install implicitly. autoUpdater.autoInstallOnAppQuit = updateInstallMode === 'interactive' // Why: MacUpdater ignores quitAndInstall arguments; the surviving CLI supervisor must be the only serve relaunch owner. @@ -1457,10 +1572,12 @@ export function setupAutoUpdater( // Security: never re-add a verifyUpdateCodeSignature override — a no-op disables electron-updater's built-in Authenticode check and accepts any installer. // Why: generic provider avoids the native GitHub provider's RC-channel filtering; per-check repinning to a concrete /releases/download// URL avoids /latest redirect drift between check and download. - autoUpdater.setFeedURL({ - provider: 'generic', - url: 'https://github.com/stablyai/orca/releases/latest/download' - }) + if (activeUpdateSource === 'release') { + autoUpdater.setFeedURL({ + provider: 'generic', + url: 'https://github.com/stablyai/orca/releases/latest/download' + }) + } if (autoUpdaterInitialized) { return @@ -1480,7 +1597,8 @@ export function setupAutoUpdater( getUserInitiatedCheck: () => userInitiatedCheck, handleQuitAndInstallFailure, isQuitAndInstallHandoffActive, - hasNewerDownloadedVersion, + hasInstallableDownloadedVersion, + isLocalBuildCheck: () => activeUpdateSource === 'local', shouldHandleUpdaterErrorEvent, performQuitAndInstall, clearUpdateAvailableEventPending, @@ -1494,6 +1612,7 @@ export function setupAutoUpdater( shouldSuppressMissingManifestPrereleaseFallbackEvent, suppressMissingManifestPrereleaseFallbackPromiseFailure, recordCompletedUpdateCheck, + restoreReleaseUpdateSource, sendStatus, scheduleAutomaticUpdateCheck, clearBackgroundCheckLaunchPending, @@ -1544,13 +1663,13 @@ export function setupAutoUpdater( } export function downloadUpdate(): void { - if (downloadInFlight) { + if (localBuildSelectionInProgress || downloadInFlight) { return } // Why: allow retry from 'error' (availableVersion stays cached) so the error card's Retry Download button works. const canStart = currentStatus.state === 'available' || - (currentStatus.state === 'error' && hasNewerDownloadedVersion()) + (currentStatus.state === 'error' && hasInstallableDownloadedVersion()) if (!canStart) { return } @@ -1562,6 +1681,7 @@ export function downloadUpdate(): void { return } downloadInFlight = true + const localBuildDownload = activeUpdateSource === 'local' beginMacUpdateDownload() // Why: setup can take seconds before progress emits; surface acceptance now so the action never looks inert. sendStatus({ state: 'downloading', percent: 0, version }) @@ -1569,6 +1689,11 @@ export function downloadUpdate(): void { .downloadUpdate() .catch((err) => { downloadInFlight = false - sendErrorStatus(String(err?.message ?? err)) + const message = String(err?.message ?? err) + if (localBuildDownload) { + sendLocalBuildErrorAndRestore(message) + } else { + sendErrorStatus(message) + } }) } diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index 0a481b209bc..5384d11c16a 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -23,7 +23,7 @@ import { registerSshHandlers } from '../ipc/ssh' import { registerRemoteWorkspaceHandlers } from '../ipc/remote-workspace' import { browserManager } from '../browser/browser-manager' import { hasSystemMediaAccess, requestSystemMediaAccess } from '../browser/browser-media-access' -import type { OrcaRuntimeService } from '../runtime/orca-runtime' +import type { OrcaRuntimeService, RuntimeWorktreeLifecycleEvent } from '../runtime/orca-runtime' import { checkForUpdatesFromMenu, downloadUpdate, @@ -84,11 +84,14 @@ export function attachMainWindowServices( isRecoveryReloadInFlight?: (webContentsId: number) => boolean onBeforeUpdateQuit?: () => void | Promise updateInstallMode?: UpdateInstallMode + onWorktreeLifecycle?: (event: RuntimeWorktreeLifecycleEvent) => void } ): void { registerAppReloadHandler(mainWindow, options?.onBeforeRendererReload) registerRepoHandlers(mainWindow, store) - registerWorktreeHandlers(mainWindow, store, runtime) + registerWorktreeHandlers(mainWindow, store, runtime, { + onWorktreeLifecycle: options?.onWorktreeLifecycle + }) // Why: repo/settings mutations resync watchers through this attached main-window context. setWorktreeBaseDirectoryWatcherSyncContext(store, mainWindow) scheduleWorktreeBaseDirectoryWatcherSync(store, mainWindow) diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 387f0d2b5f4..909ed473e86 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -50,6 +50,7 @@ import { closeDashboardPopout } from './dashboard-popout-window' import { installPrivilegedWindowNavigationPolicy } from './privileged-window-navigation' import { isMacosTahoeOrNewer } from './macos-tahoe-release' import { reflowRendererViewport } from './renderer-viewport-reflow' +import { registerPluginPanelNavigationGuard } from '../plugins/plugin-panel-navigation-guard' // Why: show/restore/resume can overlap before the size nudge resets; never capture the temporary width as the next baseline. const activeRepaintJiggles = new WeakSet() @@ -441,6 +442,9 @@ export function createMainWindow( }) installPrivilegedWindowNavigationPolicy(mainWindow.webContents) + // Why: containment must be listening before any plugin panel frame is created, + // so register it with the window's other navigation policy. + registerPluginPanelNavigationGuard(mainWindow.webContents) mainWindow.webContents.on('will-attach-webview', (event, webPreferences, params) => { const src = typeof params.src === 'string' ? params.src : '' diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 2592e08e9e6..5a03fcdaf2a 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -68,6 +68,15 @@ import type { AgentProviderSessionMetadata, SleepingAgentLaunchConfig } from '../shared/agent-session-resume' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' +import type { PluginLanguagePackRegistration } from '../shared/plugins/plugin-language-pack-artifact' +import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' +import type { PluginManifest } from '../shared/plugins/plugin-manifest' +import type { PluginMarketplaceGitSource } from '../shared/plugins/plugin-marketplace' import type { LocalhostWorktreeLabelResult, LocalhostWorktreeLabelRoute @@ -236,6 +245,7 @@ import type { WorkspaceSessionState } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { PtyListedSession } from '../shared/pty-listed-session' import type { PtyRendererDeliveryHealthReply, PtyRendererDeliveryStateReport @@ -969,6 +979,134 @@ export type AppApi = { ) => Promise } +/** Panel contribution as surfaced by the main-process plugin service. */ +export type PluginHostPanel = { + id: string + title: string + /** Lucide icon name declared in the plugin manifest. */ + icon?: string + tabKey: `plugin:${string}` +} + +/** `pending` = awaiting (re-)consent; `idle` = enabled, worker not running + * (lazy); `restarting` = waiting for supervised backoff; `errored` = crashed past the restart budget or failed to start; + * `invalid` = unreadable manifest. */ +export type PluginHostStatus = + | 'running' + | 'restarting' + | 'idle' + | 'pending' + | 'disabled' + | 'errored' + | 'invalid' + +/** Wire shape of plugins:list — must stay assignable from the main-process + * projection in src/main/plugins/plugin-list-projection.ts. */ +export type PluginHostListEntry = { + pluginKey: string + consentFingerprint: string | null + name: string + version: string + publisher: string + description?: string + status: PluginHostStatus + needsReconsent: boolean + error?: string + isDev: boolean + official: boolean + bundled: boolean + capabilities: { kind: string; description: string }[] + panels: PluginHostPanel[] + commands: { + id: string + title: string + context: 'global' | 'worktree' + handler: { type: 'built-in'; action: string } | { type: 'worker' } + keybindings: { key: string; when: 'global' | 'worktree' }[] + }[] + hasWorker: boolean + vmRecipes?: { + id: string + name: string + description?: string + commands: { + phase: 'create' | 'suspend' | 'resume' | 'destroy' + command: string + }[] + }[] + restarts: number + blockedByKillList?: { reason: string; advisoryUrl?: string } + source?: { + kind: 'local-path' | 'git' | 'marketplace' | 'bundled' + reference: string + resolvedCommit: string | null + contentHash: string + marketplace?: { reference: string; resolvedCommit: string } + } +} + +export type PluginHostLogLine = { ts: number; level: 'info' | 'warn' | 'error'; line: string } + +export type PluginHostInstallSource = + | { kind: 'local-path'; path: string } + | { kind: 'git'; url: string; ref: string } + +export type PluginHostInstallResult = + | { + ok: true + pluginKey: string + version: string + contentHash: string + consentFingerprint: string + resolvedCommit: string | null + } + | { ok: false; error: string } + +export type PluginMarketplaceHostSourceState = { + id: string + source: PluginMarketplaceGitSource + addedAt: number + marketplace: { + name: string + owner: string + resolvedCommit: string + fetchedAt: number + } | null + stale: boolean + official: boolean + error?: string +} + +export type PluginMarketplaceHostListing = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + description?: string + categories: string[] + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + +export type PluginMarketplaceHostInstallPreview = { + marketplaceSourceId: string + marketplaceName: string + marketplaceOwner: string + marketplaceCommit: string + pluginKey: string + source: PluginMarketplaceGitSource + resolvedCommit: string + contentHash: string + consentFingerprint: string + manifest: PluginManifest + official: boolean + bundled: boolean + blockedByKillList?: { reason: string; advisoryUrl?: string } +} + export type PreloadApi = { app: AppApi orcaProfiles: { @@ -1384,7 +1522,7 @@ export type PreloadApi = { confirmForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise getSize: (id: string) => Promise<{ cols: number; rows: number } | null> - listSessions: () => Promise<{ id: string; cwd: string; title: string }[]> + listSessions: () => Promise getAuthoritativeBufferSnapshotCapabilities?: ( ids: string[] ) => { id: string; authoritative: boolean | null }[] @@ -3117,6 +3255,8 @@ export type PreloadApi = { selector: string timeoutMs?: number }) => Promise> + // Why: system resume / browser online advance pending shared-control reconnect timers only. + retryConnectionsNow?: () => Promise call: (args: { selector: string method: string @@ -3254,6 +3394,62 @@ export type PreloadApi = { gitBash: { isAvailable: () => Promise } + plugins: { + list: () => Promise + listLanguagePacks: () => Promise + /** Records the consent-dialog answer; approval is keyed to the plugin's + * current capability and trusted-worker fingerprint. */ + consent: (args: PluginConsentRequest) => Promise + setEnabled: (args: { pluginKey: string; enabled: boolean }) => Promise + /** Returns the panel's CSP-wrapped HTML, or null when the plugin or + * panel is missing/disabled. Rendered only inside a sandboxed iframe. */ + readPanelEntry: (args: { + pluginKey: string + panelId: string + }) => Promise + invokeCommand: (args: { + pluginKey: string + commandId: string + args?: unknown + }) => Promise + /** Relays a sandboxed panel's bridge request to main, which enforces the + * plugin's consented capabilities before executing. */ + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }) => Promise + install: (source: PluginHostInstallSource) => Promise + listMarketplaces: () => Promise + addMarketplace: ( + source: PluginMarketplaceGitSource + ) => Promise + removeMarketplace: (args: { sourceId: string }) => Promise + refreshMarketplaces: (args?: { + sourceId?: string + }) => Promise + listMarketplacePlugins: () => Promise + previewMarketplacePlugin: (args: { + marketplaceSourceId: string + pluginKey: string + }) => Promise + installMarketplacePlugin: ( + preview: Pick< + PluginMarketplaceHostInstallPreview, + 'marketplaceSourceId' | 'marketplaceCommit' | 'pluginKey' | 'resolvedCommit' + > + ) => Promise + previewMarketplaceUpdate: (args: { + pluginKey: string + }) => Promise + rollbackMarketplacePlugin: (args: { pluginKey: string }) => Promise + remove: (args: { pluginKey: string }) => Promise + getLogs: (args: { pluginKey: string }) => Promise + /** Re-discovers after settings edits (feature flag, dev paths). */ + refresh: () => Promise + /** Fires whenever installed plugins, worker states, panels, or content packs change. */ + onChanged: (callback: (event: PluginChangeEvent) => void) => () => void + } agentStatus: { /** Listen for agent status updates forwarded from native hook receivers. */ onSet: (callback: (data: AgentStatusIpcPayload) => void) => () => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 21014fc1a35..fd0b89d9b04 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,12 @@ import type { import type { MobileRelayStatus } from '../shared/mobile-relay-status' import type { MobilePairingConnectionMode } from '../shared/mobile-pairing-connection-mode' import type { SshMutationExpectation } from '../shared/ssh-types' +import type { + PluginPanelActionOutcome, + PluginPanelEntry +} from '../shared/plugins/plugin-panel-bridge' +import type { PluginConsentRequest } from '../shared/plugins/plugin-consent-request' +import type { PluginChangeEvent } from '../shared/plugins/plugin-change-event' import type { BaseRefSearchResult, BaseRefDefaultResult, @@ -68,6 +74,7 @@ import type { WorktreeRemoteBranchConflictEvent } from '../shared/types' import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { PtyListedSession } from '../shared/pty-listed-session' import type { PtyRendererDeliveryHealthReply, PtyRendererDeliveryStateReport @@ -241,7 +248,13 @@ import type { } from '../shared/crash-reporting' import type { RendererHeapStatistics } from '../shared/renderer-heap-statistics' import { readRendererHeapStatistics } from './renderer-heap-statistics-reader' -import type { PreloadApi } from './api-types' +import type { + PluginHostInstallResult, + PluginHostInstallSource, + PluginHostListEntry, + PluginHostLogLine, + PreloadApi +} from './api-types' import { createUpdaterQuitAbortRelay, prepareRendererForAppRestart @@ -521,6 +534,57 @@ const api = { isAvailable: (): Promise => ipcRenderer.invoke('gitBash:isAvailable') }, + plugins: { + list: (): Promise => ipcRenderer.invoke('plugins:list'), + listLanguagePacks: () => ipcRenderer.invoke('plugins:listLanguagePacks'), + consent: (args: PluginConsentRequest): Promise => + ipcRenderer.invoke('plugins:consent', args), + setEnabled: (args: { pluginKey: string; enabled: boolean }): Promise => + ipcRenderer.invoke('plugins:setEnabled', args), + readPanelEntry: (args: { + pluginKey: string + panelId: string + }): Promise => ipcRenderer.invoke('plugins:readPanelEntry', args), + invokeCommand: (args: { + pluginKey: string + commandId: string + args?: unknown + }): Promise => ipcRenderer.invoke('plugins:invokeCommand', args), + panelAction: (args: { + sessionToken: string + action: string + params?: unknown + }): Promise => ipcRenderer.invoke('plugins:panelAction', args), + install: (source: PluginHostInstallSource): Promise => + ipcRenderer.invoke('plugins:install', source), + listMarketplaces: () => ipcRenderer.invoke('plugins:listMarketplaces'), + addMarketplace: (source) => ipcRenderer.invoke('plugins:addMarketplace', source), + removeMarketplace: (args) => ipcRenderer.invoke('plugins:removeMarketplace', args), + refreshMarketplaces: (args = {}) => ipcRenderer.invoke('plugins:refreshMarketplaces', args), + listMarketplacePlugins: () => ipcRenderer.invoke('plugins:listMarketplacePlugins'), + previewMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:previewMarketplacePlugin', args), + installMarketplacePlugin: (preview) => + ipcRenderer.invoke('plugins:installMarketplacePlugin', preview), + previewMarketplaceUpdate: (args) => + ipcRenderer.invoke('plugins:previewMarketplaceUpdate', args), + rollbackMarketplacePlugin: (args) => + ipcRenderer.invoke('plugins:rollbackMarketplacePlugin', args), + remove: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:remove', args), + getLogs: (args: { pluginKey: string }): Promise => + ipcRenderer.invoke('plugins:getLogs', args), + refresh: (): Promise => ipcRenderer.invoke('plugins:refresh'), + onChanged: (callback): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, change: PluginChangeEvent): void => + callback(change) + ipcRenderer.on('plugins:changed', listener) + return () => { + ipcRenderer.removeListener('plugins:changed', listener) + } + } + } satisfies PreloadApi['plugins'], + repos: { list: () => ipcRenderer.invoke('repos:list'), @@ -928,8 +992,7 @@ const api = { kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), - listSessions: (): Promise<{ id: string; cwd: string; title: string }[]> => - ipcRenderer.invoke('pty:listSessions'), + listSessions: (): Promise => ipcRenderer.invoke('pty:listSessions'), getAuthoritativeBufferSnapshotCapabilities: ( ids: string[] ): { id: string; authoritative: boolean | null }[] => @@ -4126,6 +4189,8 @@ const api = { timeoutMs?: number }): Promise> => ipcRenderer.invoke('runtimeEnvironments:getStatus', args), + retryConnectionsNow: (): Promise => + ipcRenderer.invoke('runtimeEnvironments:retryConnectionsNow'), call: (args: { selector: string method: string diff --git a/src/relay/plugin-host-call-handler.ts b/src/relay/plugin-host-call-handler.ts new file mode 100644 index 00000000000..c3f73c53c7b --- /dev/null +++ b/src/relay/plugin-host-call-handler.ts @@ -0,0 +1,69 @@ +import type { MethodHandler, RequestContext } from './dispatcher' +import { + admitPluginPanelCall, + createPluginPanelCallAdmission, + type PluginPanelCallAdmission +} from '../shared/plugins/plugin-panel-call-admission' +import { + executePluginHostCallRequest, + isPluginHostCallRequest, + type ResolvePluginHostCallPolicy +} from '../main/plugins/plugin-host-call-adapter' + +export const RELAY_PLUGIN_PANEL_HOST_CALL_METHOD = 'plugins.hostCall.panel' +export const RELAY_PLUGIN_WORKER_HOST_CALL_METHOD = 'plugins.hostCall.worker' + +export type RelayPluginHostCallDispatcher = { + onRequest(method: string, handler: MethodHandler): void +} + +export type ResolveRelayPluginHostCallIdentity = ( + context: RequestContext +) => string | null | Promise + +/** Relay provisioning is deliberately out of scope here. Its connection- + * keyed resolver owns plugin identity, consent, services, and audit authority. */ +export function registerRelayPluginHostCallHandlers( + dispatcher: RelayPluginHostCallDispatcher, + resolveIdentity: ResolveRelayPluginHostCallIdentity, + resolvePolicy: ResolvePluginHostCallPolicy, + options: { panelAdmission?: PluginPanelCallAdmission } = {} +): void { + const panelAdmission = options.panelAdmission ?? createPluginPanelCallAdmission() + const register = (registeredMethod: string, viaPanel: boolean): void => { + dispatcher.onRequest(registeredMethod, async (params, context) => { + let pluginKey: string | null + try { + pluginKey = await resolveIdentity(context) + } catch { + pluginKey = null + } + if (!pluginKey) { + return { + ok: false, + code: 'unavailable', + error: 'plugin host authority is not available' + } + } + if (viaPanel) { + const admissionRefusal = admitPluginPanelCall(panelAdmission, pluginKey, params) + if (admissionRefusal) { + return admissionRefusal + } + } + if (!isPluginHostCallRequest(params)) { + return { ok: false, code: 'invalid_request', error: 'malformed plugin host call request' } + } + return executePluginHostCallRequest({ + pluginKey, + request: params, + viaPanel, + resolvePolicy + }) + }) + } + // Why: transport authority is fixed by the registered RPC method; callers + // cannot promote a panel call to the wider worker method set in params. + register(RELAY_PLUGIN_PANEL_HOST_CALL_METHOD, true) + register(RELAY_PLUGIN_WORKER_HOST_CALL_METHOD, false) +} diff --git a/src/relay/pty-handler.test.ts b/src/relay/pty-handler.test.ts index 3e3bbebd9fd..3a2f1ae8ebf 100644 --- a/src/relay/pty-handler.test.ts +++ b/src/relay/pty-handler.test.ts @@ -36,7 +36,8 @@ import { IMMEDIATE_PTY_EXIT_TIMEOUT_MS, MAX_RELAY_PTY_SESSIONS, PtyHandler, - attachIdentityMismatches + attachIdentityMismatches, + formatNodePtyUnavailableMessage } from './pty-handler' import type { RelayDispatcher } from './dispatcher' @@ -373,6 +374,24 @@ describe('PtyHandler', () => { expect(mockPtySpawn).toHaveBeenCalledOnce() }) + it('hedges both causes on Linux and offers the build-tools remedy nowhere else', () => { + const linux = formatNodePtyUnavailableMessage('linux') + expect(linux).toContain('Remote terminals are unavailable') + // Conditional, not asserted: a host with build-essential can still hit an ABI/Node-version flip. + expect(linux).toMatch(/If it is missing the C\/C\+\+ build tools/) + expect(linux).toContain('python3') + expect(linux).toContain('version and architecture match the installed binding') + + // Windows/macOS ship node-pty prebuilds, so "install make/g++/python3" sends the user chasing nothing. + for (const platform of ['win32', 'darwin'] as const) { + const message = formatNodePtyUnavailableMessage(platform) + expect(message).toContain('Remote terminals are unavailable') + expect(message).not.toContain('build tools') + expect(message).not.toContain('python3') + expect(message).toMatch(/reconnect/i) + } + }) + it('normalizes a missing native binding as degraded node-pty availability', async () => { mockPtySpawn.mockImplementationOnce(() => { throw new Error( @@ -381,7 +400,7 @@ describe('PtyHandler', () => { }) await expect(dispatcher.callRequest('pty.spawn', {})).rejects.toThrow( - 'node-pty is not available on this remote host' + 'Remote terminals are unavailable' ) expect(handler.activePtyCount).toBe(0) }) diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index b108d675725..578858d16b8 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -48,6 +48,7 @@ import { type PtyIngressEmission } from '../shared/pty-startup-ingress' import { resolvePtyOwnerBackend, type PtyOwnerBackend } from '../shared/pty-owner-backend' +import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' import { agentSessionOwnerBindingsEqual, ClaimedAgentPtyOwnerRegistry @@ -60,6 +61,17 @@ import { type AgentSessionOwnerBinding } from '../shared/agent-session-host-authority' +// Why: only Linux compiles node-pty (no prebuilt), so the build-tools remedy is a closable setup gap +// there and wrong advice anywhere node-pty ships one. The relay only sees an unloadable binding, never +// why — a skipped compile and a later Node/ABI flip look identical here — so Linux hedges both causes. +export function formatNodePtyUnavailableMessage(platform: NodeJS.Platform): string { + const remedy = + platform === 'linux' + ? "node-pty's native binding is not loadable on this host. If it is missing the C/C++ build tools needed to compile node-pty, install make, a C++ compiler, and python3 on the remote host, then reconnect. Otherwise reconnect to reinstall the relay's native modules, and check that the remote Node.js version and architecture match the installed binding." + : "node-pty's native binding failed to load on this host. Reconnect to reinstall the relay's native modules; if it persists, check that the remote Node.js version and architecture match the installed binding." + return `Remote terminals are unavailable: ${remedy}` +} + function isMissingNodePtyNativeBinding(error: unknown): boolean { return ( error instanceof Error && @@ -72,7 +84,9 @@ type ManagedPty = { incarnationId: string pty: IPty initialCwd: string - buffered: string + /** Why a chunk deque: rebuilding a rolling 100KB string per PTY chunk copied the + * whole window on every write once saturated. Readers are attach/adopt/revive only. */ + buffered: RecentPtyOutputBuffer /** Timer for SIGKILL fallback after a graceful SIGTERM shutdown. */ killTimer?: ReturnType /** True once disposeManagedPty has run; blocks double-dispose and makes post-dispose calls fail "not found" not silently. */ @@ -473,10 +487,7 @@ export class PtyHandler { if (data.length === 0) { return } - managed.buffered += data - if (managed.buffered.length > REPLAY_BUFFER_MAX) { - managed.buffered = managed.buffered.slice(-REPLAY_BUFFER_MAX) - } + managed.buffered.append(data) } private releaseStartupCommand(managed: ManagedPty): void { @@ -969,13 +980,12 @@ export class PtyHandler { throw new Error('agent_session_exited_during_start') } managed.agentSessionOwners = this.agentSessionOwners.listForPty(managed.id) + const adoptedReplay = result.disposition === 'adopted' ? managed.buffered.read() : '' return { id: managed.id, incarnationId: managed.incarnationId, agentSessionEnsure: result, - ...(result.disposition === 'adopted' && managed.buffered - ? { replay: managed.buffered } - : {}) + ...(adoptedReplay ? { replay: adoptedReplay } : {}) } } catch (error) { if (!physicalSpawnCommitted) { @@ -997,7 +1007,7 @@ export class PtyHandler { ): Promise<{ id: string; incarnationId: string }> { const pty = await this.loadPty() if (!pty) { - throw new Error('node-pty is not available on this remote host') + throw new Error(formatNodePtyUnavailableMessage(process.platform)) } const cols = (params.cols as number) || 80 @@ -1078,7 +1088,7 @@ export class PtyHandler { // Why: Windows loads conpty.node only on first spawn, so handle that late binding failure here. if (isMissingNodePtyNativeBinding(error)) { this.invalidatePtyModuleAfterBindingFailure() - throw new Error('node-pty is not available on this remote host') + throw new Error(formatNodePtyUnavailableMessage(process.platform)) } throw error } @@ -1100,7 +1110,10 @@ export class PtyHandler { incarnationId: randomUUID(), pty: term, initialCwd: cwd, - buffered: '', + buffered: new RecentPtyOutputBuffer({ + preserveChunkBoundaries: false, + limit: REPLAY_BUFFER_MAX + }), paneKey, tabId, ...(attachIdentity.paneKey || attachIdentity.tabId ? { attachIdentity } : {}), @@ -1187,14 +1200,15 @@ export class PtyHandler { // Why: renderer hasn't registered replay handlers yet during spawn, so return to the caller instead of notifying too early. // Why: buffer intentionally NOT cleared after replay (client clears xterm first) so later restarts still replay full history. - if (managed.buffered) { + const replay = managed.buffered.read() + if (replay) { // Why: drop pending batched bytes already in the replay buffer so attach doesn't render them twice. this.pendingOutputByPty.delete(id) this.clearOutputFlushTimerIfIdle() if (params.suppressReplayNotification) { - return { incarnationId: managed.incarnationId, replay: managed.buffered } + return { incarnationId: managed.incarnationId, replay } } - this.dispatcher.notify('pty.replay', { id, data: managed.buffered }) + this.dispatcher.notify('pty.replay', { id, data: replay }) } return { incarnationId: managed.incarnationId } } @@ -1541,7 +1555,10 @@ export class PtyHandler { incarnationId: randomUUID(), pty: term, initialCwd: entry.cwd, - buffered: '', + buffered: new RecentPtyOutputBuffer({ + preserveChunkBoundaries: false, + limit: REPLAY_BUFFER_MAX + }), paneKey: entry.paneKey, tabId: entry.tabId, attachIdentity: entry.attachIdentity, diff --git a/src/relay/pty-replay-buffer-equivalence.test.ts b/src/relay/pty-replay-buffer-equivalence.test.ts new file mode 100644 index 00000000000..57b35ab8480 --- /dev/null +++ b/src/relay/pty-replay-buffer-equivalence.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { RecentPtyOutputBuffer } from '../main/runtime/recent-pty-output-buffer' +import { REPLAY_BUFFER_MAX } from './pty-handler' + +// Reference: the pre-change replay buffer, a rolling string sliced per append. +function appendStringTail(previous: string, data: string): string { + if (data.length === 0) { + return previous + } + const next = previous + data + return next.length > REPLAY_BUFFER_MAX ? next.slice(-REPLAY_BUFFER_MAX) : next +} + +function makeBuffer(): RecentPtyOutputBuffer { + return new RecentPtyOutputBuffer({ preserveChunkBoundaries: false, limit: REPLAY_BUFFER_MAX }) +} + +function replayEquals(chunks: string[]): { deque: string; reference: string } { + const buffer = makeBuffer() + let reference = '' + for (const chunk of chunks) { + buffer.append(chunk) + reference = appendStringTail(reference, chunk) + } + return { deque: buffer.read(), reference } +} + +// Deterministic PRNG so a failure is reproducible. +function makeRandom(seed: number): () => number { + let state = seed >>> 0 + return () => { + state = (state * 1664525 + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +describe('relay replay buffer equivalence', () => { + it('matches the rolling-string tail below the cap', () => { + const { deque, reference } = replayEquals(['hello ', 'world', '\r\n$ ']) + expect(deque).toBe(reference) + expect(deque).toBe('hello world\r\n$ ') + }) + + it('matches once the window saturates', () => { + const chunks = Array.from({ length: 40 }, (_value, index) => `chunk-${index}-`.repeat(400)) + const { deque, reference } = replayEquals(chunks) + expect(deque.length).toBe(REPLAY_BUFFER_MAX) + expect(deque).toBe(reference) + }) + + it('matches when one append alone exceeds the cap', () => { + const { deque, reference } = replayEquals(['x'.repeat(REPLAY_BUFFER_MAX * 2 + 7)]) + expect(deque.length).toBe(REPLAY_BUFFER_MAX) + expect(deque).toBe(reference) + }) + + it('matches when an oversized append follows existing content', () => { + const { deque, reference } = replayEquals(['prefix', 'y'.repeat(REPLAY_BUFFER_MAX + 3)]) + expect(deque).toBe(reference) + expect(deque.startsWith('prefix')).toBe(false) + }) + + it('matches at exactly the cap boundary', () => { + for (const total of [REPLAY_BUFFER_MAX - 1, REPLAY_BUFFER_MAX, REPLAY_BUFFER_MAX + 1]) { + const { deque, reference } = replayEquals(['a'.repeat(total)]) + expect(deque).toBe(reference) + } + }) + + it('ignores empty appends exactly as the string form did', () => { + const { deque, reference } = replayEquals(['a', '', 'b', '', 'c']) + expect(deque).toBe(reference) + expect(deque).toBe('abc') + }) + + it('is stable across repeated reads', () => { + const buffer = makeBuffer() + for (let index = 0; index < 200; index += 1) { + buffer.append(`line ${index}\r\n`.repeat(30)) + } + expect(buffer.read()).toBe(buffer.read()) + }) + + it('keeps appending correctly after a read collapses the deque', () => { + const buffer = makeBuffer() + let reference = '' + for (let index = 0; index < 60; index += 1) { + const chunk = `mid-${index}-`.repeat(300) + buffer.append(chunk) + reference = appendStringTail(reference, chunk) + // Interleave reads: attach/adopt/revive can land at any point in the stream. + if (index % 7 === 0) { + expect(buffer.read()).toBe(reference) + } + } + expect(buffer.read()).toBe(reference) + }) + + // Why fuzz: the deque trims across chunk boundaries with a deferred head offset, + // so the risky cases are irregular chunk sizes straddling the cap repeatedly. + it('matches the rolling string across randomized chunk streams', () => { + for (let seed = 1; seed <= 40; seed += 1) { + const random = makeRandom(seed) + const buffer = makeBuffer() + let reference = '' + for (let step = 0; step < 120; step += 1) { + const size = Math.floor(random() * (REPLAY_BUFFER_MAX / 8)) + const chunk = String.fromCharCode(97 + (step % 26)).repeat(size) + buffer.append(chunk) + reference = appendStringTail(reference, chunk) + } + expect(buffer.read(), `seed ${seed}`).toBe(reference) + } + }) + + // Why surrogates: trimming by code unit can split a pair, and the string form did + // exactly the same thing. The deque must not "fix" it into a different tail. + it('splits surrogate pairs identically to the rolling string', () => { + // Why the trailing unit: the cap is even and a pair is 2 units, so an emoji run + // alone always cuts on a pair boundary. One odd unit shifts the cut mid-pair. + const { deque, reference } = replayEquals([`${'\u{1F600}'.repeat(REPLAY_BUFFER_MAX)}z`]) + expect(deque).toBe(reference) + expect(deque.length).toBe(REPLAY_BUFFER_MAX) + const leadUnit = deque.charCodeAt(0) + expect(leadUnit).toBeGreaterThanOrEqual(0xdc00) + expect(leadUnit).toBeLessThanOrEqual(0xdfff) + }) + + it('keeps a pair-aligned tail intact when the cut lands on a boundary', () => { + const { deque, reference } = replayEquals(['\u{1F600}'.repeat(REPLAY_BUFFER_MAX)]) + expect(deque).toBe(reference) + const leadUnit = deque.charCodeAt(0) + expect(leadUnit).toBeGreaterThanOrEqual(0xd800) + expect(leadUnit).toBeLessThanOrEqual(0xdbff) + }) +}) diff --git a/src/relay/relay.ts b/src/relay/relay.ts index 71e41d605c3..267b9ee8b36 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -52,6 +52,7 @@ import { relayLogLine } from './relay-diagnostic-log' import { remoteCliRequestTimeoutMs } from './remote-cli-timeout' import { shouldReadRemoteCliStdin } from './remote-cli-stdin' import { registerManagedHookInstaller } from './managed-hook-installer' +import { registerRelayPluginHostCallHandlers } from './plugin-host-call-handler' const DEFAULT_GRACE_MS = DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS * 1000 const SOCK_NAME = 'relay.sock' @@ -428,6 +429,14 @@ async function main(): Promise { const _workspaceSessionHandler = new WorkspaceSessionHandler(dispatcher) void _workspaceSessionHandler + // Why: relay-hosted plugin provisioning is a later phase. Register the + // enforcement boundary now with no consented identities or runtime services. + registerRelayPluginHostCallHandlers( + dispatcher, + () => null, + () => ({ grantedCapabilities: null, services: null }) + ) + dispatcher.onRequest('orca.cli', async (params, context) => { return await dispatcher.requestAnyClient('orca.cli', params, { excludeClientId: context.clientId, diff --git a/src/relay/subprocess.test.ts b/src/relay/subprocess.test.ts index c772a8e514a..e80a3ccec86 100644 --- a/src/relay/subprocess.test.ts +++ b/src/relay/subprocess.test.ts @@ -146,7 +146,7 @@ describe('Subprocess: Relay entry point', () => { const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) const failed = await relay.waitForResponse(failedId) - expect(failed.error?.message).toContain('node-pty is not available') + expect(failed.error?.message).toContain('Remote terminals are unavailable') writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE) @@ -171,7 +171,7 @@ describe('Subprocess: Relay entry point', () => { const failedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) const failed = await relay.waitForResponse(failedId) - expect(failed.error?.message).toContain('node-pty is not available') + expect(failed.error?.message).toContain('Remote terminals are unavailable') writeMockNodePty(tmpDir, WORKING_NODE_PTY_MODULE, true) const repairedId = relay.send('pty.spawn', { cols: 80, rows: 24 }) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a58f1c5c474..bf860eadfc2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -181,6 +181,11 @@ import { type KeybindingContext, type PhysicalModifierToken } from '../../shared/keybindings' +import { PLUGIN_COMMAND_ALIAS_ACTION_IDS } from '../../shared/plugins/plugin-command-actions' +import { registerAppCommandDispatcher } from '@/lib/app-command-dispatch' +import { executePluginCommand } from '@/lib/plugin-command-execution' +import { findPluginCommandForKeybinding } from '@/lib/plugin-command-keybindings' +import { usePluginCommands } from '@/store/plugin-panels' import { getRepoExecutionHostId, isRuntimeOwnedSshTargetId, @@ -203,6 +208,7 @@ import { hasRequestedBackgroundTerminalWorktreeMount, subscribeBackgroundTerminalWorktreeMountRequests } from './components/terminal/background-terminal-worktree-mount' +import { useRemoteRuntimeRecoveryTriggers } from './runtime/use-remote-runtime-recovery-triggers' // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 @@ -506,6 +512,7 @@ function App(): React.JSX.Element { hasRequestedBackgroundTerminalWorktreeMount ) const keybindings = useAppStore((s) => s.keybindings) + const pluginCommands = usePluginCommands() const updateStatus = useAppStore((s) => s.updateStatus) const activeContextualTourId = useAppStore((s) => s.activeContextualTourId) const leftSidebarShortcutLabel = useShortcutLabel('sidebar.left.toggle') @@ -668,6 +675,7 @@ function App(): React.JSX.Element { settings?.primarySelectionMiddleClickPaste ) usePrimarySelectionPaste(primarySelectionMiddleClickPaste) + useAppMenuPaste() useLargeTextControlPaste() const petEnabled = useAppStore((s) => s.settings?.experimentalPet === true) @@ -724,6 +732,7 @@ function App(): React.JSX.Element { // Subscribe to IPC push events useIpcEvents() + useRemoteRuntimeRecoveryTriggers() useAutomationDispatchEvents() // Why: retention runs at App level (in , a null leaf) so "done" agents survive card collapse and its high-churn subscriptions don't re-render App. // Why: git polling lives at App level (RightSidebar unmounts when closed, stranding stale Rebasing/Merging badges); gate on workspaceSessionReady so it doesn't compete with first paint. @@ -1494,6 +1503,7 @@ function App(): React.JSX.Element { floatingTerminalOpen, floatingVisibleTabCount, keybindings, + pluginCommands, terminalShortcutPolicy: settings?.terminalShortcutPolicy, setFloatingTerminalOpenWithFocus, workspaceChromeActive, @@ -1508,6 +1518,7 @@ function App(): React.JSX.Element { floatingTerminalOpen, floatingVisibleTabCount, keybindings, + pluginCommands, terminalShortcutPolicy: settings?.terminalShortcutPolicy, setFloatingTerminalOpenWithFocus, workspaceChromeActive, @@ -1517,6 +1528,196 @@ function App(): React.JSX.Element { useEffect(() => { const doubleTapDetector = new ModifierDoubleTapDetector() + const createRegisteredCommandHandlers = ( + input?: ShortcutDispatchInput, + keybindingContext: KeybindingContext = 'app' + ): Map boolean> => { + const { + activeView, + activeWorktreeId, + actions, + floatingTerminalEnabled, + floatingTerminalOpen, + terminalShortcutPolicy, + keybindings, + setFloatingTerminalOpenWithFocus, + workspaceChromeActive, + creationLayoutActive + } = globalShortcutStateRef.current + const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() + const canRevealRightSidebar = !creationLayoutActive && canShowRightSidebarForView(activeView) + const claim = (actionId: KeybindingActionId, run: () => void): boolean => { + input?.preventDefault() + if ( + input && + keybindingContext === 'terminal' && + (terminalShortcutPolicy ?? 'orca-first') === 'orca-first' + ) { + showTerminalShortcutCaptureNotification({ + actionId, + platform: shortcutPlatform, + keybindings + }) + } + run() + return true + } + + return new Map boolean>([ + [ + 'worktree.history.back', + () => { + if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { + return false + } + return claim('worktree.history.back', () => useAppStore.getState().goBackWorktree()) + } + ], + [ + 'worktree.history.forward', + () => { + if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { + return false + } + return claim('worktree.history.forward', () => + useAppStore.getState().goForwardWorktree() + ) + } + ], + ['sidebar.left.toggle', () => claim('sidebar.left.toggle', () => actions.toggleSidebar())], + [ + 'sidebar.sleepingWorkspaces.toggle', + () => + claim('sidebar.sleepingWorkspaces.toggle', () => { + const store = useAppStore.getState() + const nextShowSleeping = !store.showSleepingWorkspaces + store.setShowSleepingWorkspaces(nextShowSleeping) + if (nextShowSleeping) { + store.setSidebarOpen(true) + } + }) + ], + [ + 'floatingWorkspace.maximize', + () => { + if (floatingTerminalOpen || !floatingTerminalEnabled) { + return false + } + return claim('floatingWorkspace.maximize', () => { + requestFloatingTerminalOpenMaximized() + setFloatingTerminalOpenWithFocus(true) + }) + } + ], + [ + 'tab.rename', + () => { + const store = useAppStore.getState() + if ( + !workspaceChromeActive || + floatingWorkspaceFocused || + store.activeTabType !== 'terminal' || + !store.activeTabId + ) { + return false + } + return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!)) + } + ], + [ + 'workspace.rename', + () => { + if (!workspaceChromeActive || floatingWorkspaceFocused || !activeWorktreeId) { + return false + } + return claim('workspace.rename', () => { + useAppStore.getState().setSidebarOpen(true) + requestScrollToCurrentWorkspaceRevealAndRename() + }) + } + ], + [ + 'workspace.openBoard', + () => { + if (activeView === 'settings') { + return false + } + return claim('workspace.openBoard', () => { + useAppStore.getState().setSidebarOpen(true) + window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT)) + }) + } + ], + [ + 'view.tasks', + () => { + const store = useAppStore.getState() + if (activeView === 'settings' || !store.repos.some((repo) => isGitRepoKind(repo))) { + return false + } + return claim('view.tasks', () => store.openTaskPage()) + } + ], + [ + 'sidebar.right.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.right.toggle', () => actions.toggleRightSidebar()) + : false + ], + [ + 'sidebar.explorer.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.explorer.toggle', () => actions.showRightSidebarFiles()) + : false + ], + [ + 'sidebar.search.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.search.toggle', () => actions.showRightSidebarSearch()) + : false + ], + [ + 'sidebar.sourceControl.toggle', + () => { + if (!canRevealRightSidebar || document.querySelector('[data-terminal-search-root]')) { + return false + } + return claim('sidebar.sourceControl.toggle', () => { + actions.setRightSidebarTab('source-control') + actions.setRightSidebarOpen(true) + }) + } + ], + [ + 'sidebar.checks.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.checks.toggle', () => { + actions.setRightSidebarTab('checks') + actions.setRightSidebarOpen(true) + }) + : false + ], + [ + 'sidebar.ports.toggle', + () => + canRevealRightSidebar + ? claim('sidebar.ports.toggle', () => { + actions.setRightSidebarTab('ports') + actions.setRightSidebarOpen(true) + }) + : false + ] + ]) + } + + const unregisterAppCommandDispatcher = registerAppCommandDispatcher((actionId) => + (createRegisteredCommandHandlers().get(actionId) ?? (() => false))() + ) + const dispatchShortcutInput = (input: ShortcutDispatchInput): void => { const { activeView, @@ -1526,9 +1727,9 @@ function App(): React.JSX.Element { floatingTerminalOpen, floatingVisibleTabCount, keybindings, + pluginCommands, terminalShortcutPolicy, setFloatingTerminalOpenWithFocus, - workspaceChromeActive, creationLayoutActive } = globalShortcutStateRef.current @@ -1630,22 +1831,6 @@ function App(): React.JSX.Element { return } - // Cmd/Ctrl+Alt+Arrow worktree history — kept before right-sidebar shortcuts because it's navigation, not sidebar reveal. - if (matchShortcut('worktree.history.back') || matchShortcut('worktree.history.forward')) { - // Back/Forward is live wherever the titlebar cluster shows (worktree + page visits), but suppressed in Settings. - if (creationLayoutActive || !shouldShowWorktreeHistoryControls(activeView)) { - return - } - input.preventDefault() - const store = useAppStore.getState() - if (matchShortcut('worktree.history.back')) { - store.goBackWorktree() - } else { - store.goForwardWorktree() - } - return - } - // Only short-circuit chords the floating panel itself claims; suppressing others here would silently no-op them when focus is in the panel. const floatingWorkspaceFocused = isFloatingWorkspacePanelFocused() if (floatingWorkspaceFocused) { @@ -1659,139 +1844,42 @@ function App(): React.JSX.Element { } } - // Cmd/Ctrl+B — toggle left sidebar - if (matchShortcut('sidebar.left.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.left.toggle') - actions.toggleSidebar() - return - } - - // Toggle the sleeping-workspaces filter without the filters menu (issue #5209); open the sidebar when revealing so they're reachable. - if (matchShortcut('sidebar.sleepingWorkspaces.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.sleepingWorkspaces.toggle') - const store = useAppStore.getState() - const nextShowSleeping = !store.showSleepingWorkspaces - store.setShowSleepingWorkspaces(nextShowSleeping) - if (nextShowSleeping) { - store.setSidebarOpen(true) - } - return - } - - // Cmd+R renames the active terminal tab — free here because the browser pane owns its own reload; non-terminal tabs fall through (no inline title editor). - if (workspaceChromeActive && !floatingWorkspaceFocused && matchShortcut('tab.rename')) { - const store = useAppStore.getState() - if (store.activeTabType === 'terminal' && store.activeTabId) { + // Plugin chords are user-reviewed instructional content. They win over + // built-in defaults only in app focus; terminal/editor/browser handlers + // retain their own shortcut authority. + if (context === 'app') { + const pluginCommand = findPluginCommandForKeybinding( + pluginCommands, + input, + shortcutPlatform, + keybindings, + Boolean(activeWorktreeId) + ) + if (pluginCommand) { input.preventDefault() - notifyTerminalCapture('tab.rename') - store.setRenamingTabId(store.activeTabId) + void executePluginCommand(pluginCommand, 'plugin-keybinding').catch(() => { + toast.error( + translate('auto.App.pluginCommandFailed', 'Could not run the plugin command.') + ) + }) return } } - // Open/reveal the worktree card first so its inline title editor is mounted even when filters or collapse state would hide it. - if ( - workspaceChromeActive && - !floatingWorkspaceFocused && - matchShortcut('workspace.rename') && - activeWorktreeId - ) { - input.preventDefault() - notifyTerminalCapture('workspace.rename') - const store = useAppStore.getState() - store.setSidebarOpen(true) - requestScrollToCurrentWorkspaceRevealAndRename() - return - } - - if (matchShortcut('workspace.openBoard') && activeView !== 'settings') { - input.preventDefault() - notifyTerminalCapture('workspace.openBoard') - const store = useAppStore.getState() - store.setSidebarOpen(true) - window.dispatchEvent(new CustomEvent(OPEN_WORKSPACE_BOARD_EVENT)) - return - } - - // Cmd/Ctrl+N is handled in the main-process before-input-event allowlist (window-shortcut-policy.ts), not here, so it fires even inside editors/browser guests. - - // Full-page navigation surfaces own the whole content area, so don't reveal the right sidebar. - if (matchShortcut('view.tasks') && activeView !== 'settings') { - const store = useAppStore.getState() - if (store.repos.some((repo) => isGitRepoKind(repo))) { - input.preventDefault() - notifyTerminalCapture('view.tasks') - store.openTaskPage() - } - return - } - - if (!canRevealRightSidebar) { - return - } - - // Cmd/Ctrl+L — toggle right sidebar - if (matchShortcut('sidebar.right.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.right.toggle') - actions.toggleRightSidebar() - return - } - - // Cmd/Ctrl+Shift+E — toggle right sidebar / explorer tab - if (matchShortcut('sidebar.explorer.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.explorer.toggle') - actions.showRightSidebarFiles() - return - } - - // Cmd/Ctrl+Shift+F — toggle right sidebar / search tab - if (matchShortcut('sidebar.search.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.search.toggle') - openSearchSidebar(null) - return - } - - // Cmd/Ctrl+Shift+G — source control tab; skip when terminal search is open (there it means "find previous"). DOM check because capture-phase order varies. - if (matchShortcut('sidebar.sourceControl.toggle')) { - if (document.querySelector('[data-terminal-search-root]')) { + const handlers = createRegisteredCommandHandlers(input, context) + for (const actionId of PLUGIN_COMMAND_ALIAS_ACTION_IDS) { + if (matchShortcut(actionId) && handlers.get(actionId)?.()) { return } - input.preventDefault() - notifyTerminalCapture('sidebar.sourceControl.toggle') - actions.setRightSidebarTab('source-control') - actions.setRightSidebarOpen(true) - return } - // Unbound by default; opens the active worktree's Source Control notes send picker. Only consumes the chord when there are unsent notes. - if (matchShortcut('sourceControl.sendReviewNotes')) { + // Unbound by default, so it runs after the built-in alias handlers above; only consumes the chord when the active worktree has unsent notes. + if (canRevealRightSidebar && matchShortcut('sourceControl.sendReviewNotes')) { if (actions.openDiffNotesSendMenuForActiveWorktree()) { input.preventDefault() notifyTerminalCapture('sourceControl.sendReviewNotes') - return } } - - if (matchShortcut('sidebar.checks.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.checks.toggle') - actions.setRightSidebarTab('checks') - actions.setRightSidebarOpen(true) - return - } - - // Cmd+Shift+I — ports tab (macOS only); Ctrl+Shift+I is the DevTools accelerator on Windows/Linux. - if (matchShortcut('sidebar.ports.toggle')) { - input.preventDefault() - notifyTerminalCapture('sidebar.ports.toggle') - actions.setRightSidebarTab('ports') - actions.setRightSidebarOpen(true) - } } const onKeyDown = (e: KeyboardEvent): void => { @@ -1856,6 +1944,7 @@ function App(): React.JSX.Element { window.addEventListener('keyup', onKeyUp, { capture: true }) window.addEventListener('blur', onBlur) return () => { + unregisterAppCommandDispatcher() window.removeEventListener('keydown', onKeyDown, { capture: true }) window.removeEventListener('keyup', onKeyUp, { capture: true }) window.removeEventListener('blur', onBlur) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index b119d30d515..89ed0743337 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -130,6 +130,23 @@ 'SF Mono', SFMono-Regular, ui-monospace, 'Cascadia Code', Menlo, Consolas, 'Liberation Mono', monospace; --radius: 0.625rem; + --orca-security-background: #fff; + --orca-security-foreground: #0a0a0a; + --orca-security-card: #fff; + --orca-security-card-foreground: #0a0a0a; + --orca-security-popover: #fff; + --orca-security-popover-foreground: #0a0a0a; + --orca-security-primary: #171717; + --orca-security-primary-foreground: #fafafa; + --orca-security-secondary: #f5f5f5; + --orca-security-secondary-foreground: #171717; + --orca-security-muted: #f5f5f5; + --orca-security-muted-foreground: #737373; + --orca-security-accent: #f5f5f5; + --orca-security-accent-foreground: #171717; + --orca-security-border: #e5e5e5; + --orca-security-input: #e5e5e5; + --orca-security-ring: #a1a1a1; --background: #fff; --editor-surface: #ffffff; --foreground: #0a0a0a; @@ -216,6 +233,23 @@ /* ── Dark Mode ───────────────────────────────────────── */ .dark { + --orca-security-background: #0a0a0a; + --orca-security-foreground: #fafafa; + --orca-security-card: #171717; + --orca-security-card-foreground: #fafafa; + --orca-security-popover: #171717; + --orca-security-popover-foreground: #fafafa; + --orca-security-primary: #e5e5e5; + --orca-security-primary-foreground: #171717; + --orca-security-secondary: #262626; + --orca-security-secondary-foreground: #fafafa; + --orca-security-muted: #262626; + --orca-security-muted-foreground: #a1a1a1; + --orca-security-accent: #404040; + --orca-security-accent-foreground: #fafafa; + --orca-security-border: rgb(255 255 255 / 0.07); + --orca-security-input: rgb(255 255 255 / 0.15); + --orca-security-ring: #737373; --background: #0a0a0a; --editor-surface: #1e1e1e; --foreground: #fafafa; @@ -301,6 +335,28 @@ --tab-group-split-divider-strong: #a1a1aa; } +.plugin-security-chrome { + /* Why: plugin themes may style the app, but provenance and consent must + retain host-owned contrast so a pack cannot disguise a trust decision. */ + --background: var(--orca-security-background); + --foreground: var(--orca-security-foreground); + --card: var(--orca-security-card); + --card-foreground: var(--orca-security-card-foreground); + --popover: var(--orca-security-popover); + --popover-foreground: var(--orca-security-popover-foreground); + --primary: var(--orca-security-primary); + --primary-foreground: var(--orca-security-primary-foreground); + --secondary: var(--orca-security-secondary); + --secondary-foreground: var(--orca-security-secondary-foreground); + --muted: var(--orca-security-muted); + --muted-foreground: var(--orca-security-muted-foreground); + --accent: var(--orca-security-accent); + --accent-foreground: var(--orca-security-accent-foreground); + --border: var(--orca-security-border); + --input: var(--orca-security-input); + --ring: var(--orca-security-ring); +} + .linear-priority-bars { --linear-priority-bar-inactive-fill: color-mix(in srgb, lch(39.576 1.25 282) 34%, transparent); } diff --git a/src/renderer/src/assets/rich-markdown-editor.css b/src/renderer/src/assets/rich-markdown-editor.css index 4b918455221..66dddbabee2 100644 --- a/src/renderer/src/assets/rich-markdown-editor.css +++ b/src/renderer/src/assets/rich-markdown-editor.css @@ -628,13 +628,13 @@ padding-left: 0.75em; } -.rich-markdown-editor ul[data-type='taskList'] li { +.rich-markdown-editor ul[data-type='taskList'] > li { display: flex; align-items: flex-start; gap: 6px; } -.rich-markdown-editor ul[data-type='taskList'] li > label { +.rich-markdown-editor ul[data-type='taskList'] > li > label { flex-shrink: 0; display: flex; align-items: center; @@ -643,7 +643,7 @@ user-select: none; } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox'] { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox'] { appearance: none; width: 16px; height: 16px; @@ -657,12 +657,12 @@ flex-shrink: 0; } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox']:checked { background: var(--primary); border-color: var(--primary); } -.rich-markdown-editor ul[data-type='taskList'] li > label input[type='checkbox']:checked::after { +.rich-markdown-editor ul[data-type='taskList'] > li > label input[type='checkbox']:checked::after { content: ''; position: absolute; left: 4px; @@ -677,16 +677,16 @@ transform: rotate(45deg); } -.rich-markdown-editor ul[data-type='taskList'] li > div { +.rich-markdown-editor ul[data-type='taskList'] > li > div { flex: 1; min-width: 0; } -.rich-markdown-editor ul[data-type='taskList'] li > div > p { +.rich-markdown-editor ul[data-type='taskList'] > li > div > p { margin: 0; } -.rich-markdown-editor ul[data-type='taskList'] li[data-checked='true'] > div { +.rich-markdown-editor ul[data-type='taskList'] > li[data-checked='true'] > div { text-decoration: line-through; color: var(--muted-foreground); } diff --git a/src/renderer/src/assets/rich-markdown-task-list-style.test.ts b/src/renderer/src/assets/rich-markdown-task-list-style.test.ts new file mode 100644 index 00000000000..5f9a1cac42e --- /dev/null +++ b/src/renderer/src/assets/rich-markdown-task-list-style.test.ts @@ -0,0 +1,15 @@ +import fs from 'node:fs' +import { describe, expect, it } from 'vitest' + +const editorCss = fs.readFileSync(new URL('./rich-markdown-editor.css', import.meta.url), 'utf8') + +describe('rich markdown task-list styling', () => { + it('keeps flex layout scoped to direct task items', () => { + expect(editorCss).toMatch( + /\.rich-markdown-editor ul\[data-type='taskList'\] > li\s*{[^}]*display:\s*flex/s + ) + expect(editorCss).not.toMatch( + /\.rich-markdown-editor ul\[data-type='taskList'\] li\s*{[^}]*display:\s*flex/s + ) + }) +}) diff --git a/src/renderer/src/components/GitLabItemDialog.tsx b/src/renderer/src/components/GitLabItemDialog.tsx index 837fd0b1708..4d3b0f0d722 100644 --- a/src/renderer/src/components/GitLabItemDialog.tsx +++ b/src/renderer/src/components/GitLabItemDialog.tsx @@ -223,7 +223,11 @@ function CommentCard({ {comment.line ? `:${comment.line}` : ''} ) : null} - + ) } @@ -1410,7 +1414,11 @@ export default function GitLabItemDialog({ ) : null} - + ) : (
    diff --git a/src/renderer/src/components/JiraIssueWorkspace.tsx b/src/renderer/src/components/JiraIssueWorkspace.tsx index 857df73e599..dd1ccb4f2d7 100644 --- a/src/renderer/src/components/JiraIssueWorkspace.tsx +++ b/src/renderer/src/components/JiraIssueWorkspace.tsx @@ -755,8 +755,11 @@ export default function JiraIssueWorkspace({
    + {/* Why: Jira comment screenshots need the same preview + affordance without changing compact comment typography. */}
    diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index b2fc3db0aa9..d14db9fcb0e 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -7111,6 +7111,7 @@ export default function TaskPage(): React.JSX.Element { // Why: when a modal is open, let it own Esc dismissal. if ( dialogWorkItem || + selectedJiraIssue || selectedLinearIssue || newIssueOpen || newLinearIssueOpen || @@ -7155,7 +7156,8 @@ export default function TaskPage(): React.JSX.Element { newIssueOpen, newLinearIssueOpen, newJiraIssueOpen, - selectedLinearIssue + selectedLinearIssue, + selectedJiraIssue ]) useEffect(() => { diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index b25d74b5b1b..0c2982043ff 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -84,6 +84,7 @@ import { type TerminalWorktreeColdParkCandidate } from './terminal-pane/terminal-hidden-view-parking' import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' +import { useColdParkedTerminalPresentation } from './terminal-pane/use-cold-parked-terminal-presentation' import { canWatcherCoverParkedTerminalTab, disposeAllParkedTerminalWatchers, @@ -1042,6 +1043,32 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) + const desiredPresentedWorktree = useMemo( + () => + new Map([['terminal-worktree', activeView === 'terminal' ? renderedActiveWorktreeId : null]]), + [activeView, renderedActiveWorktreeId] + ) + const availablePresentedWorktreeIds = useMemo( + () => new Set(workspaceSurfaces.map((workspace) => workspace.id)), + [workspaceSurfaces] + ) + const coldWorktreePresentationTargets = useMemo( + () => + activeView === 'terminal' && activeTabType === 'terminal' + ? parkedTerminalWorktreeIds + : new Set(), + [activeTabType, activeView, parkedTerminalWorktreeIds] + ) + const { + presentationByScope: worktreePresentationByScope, + settleTarget: settleWorktreePresentation + } = useColdParkedTerminalPresentation({ + desiredTargetByScope: desiredPresentedWorktree, + coldParkedTargetIds: coldWorktreePresentationTargets, + availableTargetIds: availablePresentedWorktreeIds + }) + const presentedWorktreeId = + worktreePresentationByScope.get('terminal-worktree')?.presentedTargetId ?? null // Why: legacy (non-split) host owns watcher reconciliation; split mode's overlay layers own theirs, so only dispose worktrees with no overlay layer. useEffect(() => { pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) @@ -2076,6 +2103,7 @@ function Terminal(): React.JSX.Element | null { // Why: strict '=== terminal' (not !== settings) so the terminal/browser surface hides on the tasks page too. const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const isPresented = workspace.id === presentedWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = @@ -2090,6 +2118,7 @@ function Terminal(): React.JSX.Element | null { layout={layout} focusedGroupId={activeGroupIdByWorktree[workspace.id]} isVisible={isVisible} + isPresented={isPresented} shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} shouldColdParkTerminalPanes={shouldColdParkTerminalPanes} activityTerminalPortals={activityTerminalPortals} @@ -2099,6 +2128,7 @@ function Terminal(): React.JSX.Element | null { activationDeferredMountTabIds={ activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null } + onInitialTerminalRenderSettled={() => settleWorktreePresentation(workspace.id)} /> ) })} @@ -2125,6 +2155,7 @@ function Terminal(): React.JSX.Element | null { // Why: strict '=== terminal' (not !== settings) so the terminal/browser surface hides on the tasks page too. const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const isPresented = workspace.id === presentedWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) const shouldColdParkTerminalPanes = @@ -2135,15 +2166,16 @@ function Terminal(): React.JSX.Element | null {
    - + {(tabsByWorktree[workspace.id] ?? []) .filter((tab) => shouldMountBackgroundWorktreeTab( @@ -2159,6 +2191,9 @@ function Terminal(): React.JSX.Element | null { const isActivityPortalTab = activityTerminalPortal !== null const isActiveTerminalTab = isVisible && tab.id === activeTabId && activeTabType === 'terminal' + const isPresentedTerminalTab = + isPresented && + (isActiveTerminalTab || tab.id === activeTabIdByWorktree[workspace.id]) // Why: parking unmounts the view but keeps the PTY; an Activity portal stays mounted as a visible consumer. if (shouldColdParkTerminalPanes && !isActivityPortalTab) { return null @@ -2170,16 +2205,22 @@ function Terminal(): React.JSX.Element | null { worktreeId={workspace.id} cwd={tab.startupCwd ?? workspace.path} isActive={ - isActiveTerminalTab || activityTerminalPortal?.active === true + (isActiveTerminalTab && isPresented) || + activityTerminalPortal?.active === true } // Why: keep isVisible true for the portaled tab so xterm fits/streams while the workspace surface stays hidden. - isVisible={isActiveTerminalTab || isActivityPortalTab} + isVisible={isPresentedTerminalTab || isActivityPortalTab} // Why: inactive tabs here are tab-hidden (not worktree-hidden), so they need the same light resume path as split-group overlays. - isWorktreeActive={isVisible || isActivityPortalTab} + isWorktreeActive={isVisible || isPresented || isActivityPortalTab} // Why: isolate the portaled Activity leaf so split siblings stay hidden; workspace renders pass null. isolatedPaneKey={activityTerminalPortal?.paneKey ?? null} onPtyExit={(ptyId) => handlePtyExit(tab.id, ptyId)} onCloseTab={() => handleCloseTab(tab.id)} + onInitialRenderSettled={ + isActiveTerminalTab + ? () => settleWorktreePresentation(workspace.id) + : undefined + } /> ) if (activityTerminalPortal) { @@ -2349,22 +2390,26 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ layout, focusedGroupId, isVisible, + isPresented, shouldMeasureHiddenWorktree, shouldColdParkTerminalPanes, activityTerminalPortals, backgroundMountTabIds, - activationDeferredMountTabIds + activationDeferredMountTabIds, + onInitialTerminalRenderSettled }: { worktreeId: string worktreePath: string layout: TabGroupLayoutNode focusedGroupId?: string isVisible: boolean + isPresented: boolean shouldMeasureHiddenWorktree: boolean shouldColdParkTerminalPanes: boolean activityTerminalPortals: ActivityTerminalPortalTarget[] backgroundMountTabIds: ReadonlySet | null activationDeferredMountTabIds: ReadonlySet | null + onInitialTerminalRenderSettled: (tabId: string) => void }): React.JSX.Element { const browserPageIds = useAppStore( useShallow((state) => @@ -2381,37 +2426,45 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ return (
    - + {isVisible || backgroundMountTabIds === null ? ( <> - - + + ) : null} diff --git a/src/renderer/src/components/UpdateCard.error-card.test.tsx b/src/renderer/src/components/UpdateCard.error-card.test.tsx index cbe84536bbf..e440729f273 100644 --- a/src/renderer/src/components/UpdateCard.error-card.test.tsx +++ b/src/renderer/src/components/UpdateCard.error-card.test.tsx @@ -6,6 +6,7 @@ import { UpdateCard } from './UpdateCard' const openUrl = vi.fn() const download = vi.fn() +const check = vi.fn() function renderAfterAvailableStatus(): void { useAppStore.setState({ @@ -26,6 +27,7 @@ beforeEach(() => { useAppStore.setState(useAppStore.getInitialState(), true) openUrl.mockReset() download.mockReset() + check.mockReset() Object.defineProperty(window, 'api', { configurable: true, value: { @@ -34,7 +36,7 @@ beforeEach(() => { shell: { openUrl }, ui: { set: vi.fn().mockResolvedValue(undefined) }, updater: { - check: vi.fn(), + check, dismissNudge: vi.fn(), download, quitAndInstall: vi.fn().mockResolvedValue(undefined) @@ -94,3 +96,37 @@ describe('UpdateCard Windows signature failures', () => { ) }) }) + +describe('UpdateCard local builds', () => { + it('does not link local versions to GitHub release downloads', () => { + useAppStore.setState({ + updateStatus: { + state: 'available', + version: '1.4.100-local.1.abc', + changelog: null, + source: 'local' + }, + updateChangelog: null, + dismissedUpdateVersion: null, + updateCardCollapsed: false, + updateReassuranceSeen: true + }) + render() + + expect(screen.queryByText('Release notes')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Update' })) + expect(download).toHaveBeenCalledTimes(1) + + act(() => + useAppStore.getState().setUpdateStatus({ + state: 'error', + message: 'signature rejected', + source: 'local' + }) + ) + expect(screen.getByText('Local Build Error')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Download Manually' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Choose Another Build' })) + expect(check).toHaveBeenCalledWith({ localBuild: true }) + }) +}) diff --git a/src/renderer/src/components/UpdateCard.tsx b/src/renderer/src/components/UpdateCard.tsx index 0612c91fd39..1e04d8df26a 100644 --- a/src/renderer/src/components/UpdateCard.tsx +++ b/src/renderer/src/components/UpdateCard.tsx @@ -53,7 +53,7 @@ type ErrorCardModel = { explainer?: string /** Raw error text, shown only when the user expands "Show details". */ detail?: string - releaseUrl: string + releaseUrl?: string /** Overrides the secondary button label (defaults to "Download Manually"). */ manualLabel?: string primaryAction?: { @@ -137,6 +137,7 @@ export function UpdateCard() { // Tracks card exit so the fade-out animation plays before unmount. const [exiting, setExiting] = useState(false) const changelog: ChangelogData | null = storeChangelog + const isLocalBuild = status.source === 'local' // Why: the 'error' variant carries no version, but the card needs it for the fallback URL and dismiss; cache from states that have it. const versionRef = useRef(null) @@ -324,79 +325,104 @@ export function UpdateCard() { status.state === 'error' && isWindowsSignatureCheckUnavailableFailure(status.message) const errorCard: ErrorCardModel | null = status.state === 'error' - ? isHttp2UpdateError + ? isLocalBuild ? { - variant: 'http1Compatibility', - title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), - summary: 'Orca can retry through HTTP/1.1 compatibility mode.', - explainer: translate( - 'auto.components.UpdateCard.90559b14e3', - 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' - ), - detail: compatibilitySetupError ?? status.message, - releaseUrl: releaseUrlForVersion(cachedVersion), + title: cachedVersion + ? translate('auto.components.UpdateCard.8cf17b10af', 'Local Build Error') + : translate('auto.components.UpdateCard.a4650b0dc4', 'Could Not Use Local Build'), + summary: cachedVersion + ? translate( + 'auto.components.UpdateCard.b1e390250d', + 'Could not complete the local build switch.' + ) + : translate( + 'auto.components.UpdateCard.d29740d175', + 'The selected build could not be used.' + ), + detail: status.message, primaryAction: { - label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), - pendingLabel: 'Restarting...', - isPending: compatibilityRelaunching, - onClick: handleEnableHttp1Compatibility + label: translate('auto.components.UpdateCard.37d45c9ec1', 'Choose Another Build'), + onClick: () => { + void window.api.updater.check({ localBuild: true }) + } } } - : isSignatureMismatchError + : isHttp2UpdateError ? { - // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path. - variant: 'security', - title: translate('auto.components.UpdateCard.5b309b19f3', "Update Wasn't Installed"), - summary: translate( - 'auto.components.UpdateCard.092f09fc14', - "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version." + variant: 'http1Compatibility', + title: translate('auto.components.UpdateCard.1339b82cee', 'HTTP/2 Download Blocked'), + summary: 'Orca can retry through HTTP/1.1 compatibility mode.', + explainer: translate( + 'auto.components.UpdateCard.90559b14e3', + 'This turns on a process-wide Electron networking switch after restart. Use it for corporate VPNs or proxies that reject HTTP/2 update downloads.' ), - detail: status.message, - // Why: linking the rejected version would let users bypass the publisher check by re-running it. - releaseUrl: releaseUrlForVersion(null), - manualLabel: translate( - 'auto.components.UpdateCard.c9ff9b9ec2', - 'Check official releases' - ) + detail: compatibilitySetupError ?? status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.933c6fdf5b', 'Enable & Restart'), + pendingLabel: 'Restarting...', + isPending: compatibilityRelaunching, + onClick: handleEnableHttp1Compatibility + } } - : isSignatureCheckBlockedError + : isSignatureMismatchError ? { + // Security stop: installer signed by the wrong publisher — no retry, only a verified-download path. + variant: 'security', title: translate( - 'auto.components.UpdateCard.e944c2de43', - 'Update Verification Blocked' + 'auto.components.UpdateCard.5b309b19f3', + "Update Wasn't Installed" ), summary: translate( - 'auto.components.UpdateCard.a05992a26b', - "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases." + 'auto.components.UpdateCard.092f09fc14', + "The installer's publisher doesn't match Orca, so we stopped the update. Don't install this download; check official releases for a corrected version." ), detail: status.message, - releaseUrl: releaseUrlForVersion(cachedVersion), - primaryAction: { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate + // Why: linking the rejected version would let users bypass the publisher check by re-running it. + releaseUrl: releaseUrlForVersion(null), + manualLabel: translate( + 'auto.components.UpdateCard.c9ff9b9ec2', + 'Check official releases' + ) + } + : isSignatureCheckBlockedError + ? { + title: translate( + 'auto.components.UpdateCard.e944c2de43', + 'Update Verification Blocked' + ), + summary: translate( + 'auto.components.UpdateCard.a05992a26b', + "The signature check couldn't run — usually because antivirus software blocked it. Retry the download, or get the installer from our official releases." + ), + detail: status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + primaryAction: { + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), + onClick: handleUpdate + } } - } - : { - // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug. - title: cachedVersion ? 'Update Error' : 'Update Check Failed', - summary: cachedVersion - ? 'Could not complete the update.' - : 'Could not check for updates.', - detail: status.message, - releaseUrl: releaseUrlForVersion(cachedVersion), - // Why: check-time failures are often transient, so offer a Re-check instead of forcing manual download. - primaryAction: cachedVersion - ? { - label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), - onClick: handleUpdate - } - : { - label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), - onClick: () => { - void window.api.updater.check({ includePrerelease: false }) + : { + // Why: title is scoped to the failed operation so check-time (GitHub-side) failures don't read as an Orca bug. + title: cachedVersion ? 'Update Error' : 'Update Check Failed', + summary: cachedVersion + ? 'Could not complete the update.' + : 'Could not check for updates.', + detail: status.message, + releaseUrl: releaseUrlForVersion(cachedVersion), + // Why: check-time failures are often transient, so offer a Re-check instead of forcing manual download. + primaryAction: cachedVersion + ? { + label: translate('auto.components.UpdateCard.48565a32bc', 'Retry Download'), + onClick: handleUpdate } - } - } + : { + label: translate('auto.components.UpdateCard.6b0085010d', 'Re-check'), + onClick: () => { + void window.api.updater.check({ includePrerelease: false }) + } + } + } : installError ? { title: translate('auto.components.UpdateCard.4cf109845a', 'Update Error'), @@ -558,6 +584,7 @@ export function UpdateCard() { onMediaError={() => setMediaFailed(true)} onMediaLoad={() => setMediaLoaded(true)} onCollapse={handleCollapseWithAnimation} + showReleaseNotes={!isLocalBuild} /> ) } @@ -568,9 +595,10 @@ export function UpdateCard() { return null } - const releaseUrl = - ('releaseUrl' in status ? status.releaseUrl : undefined) ?? - releaseUrlForVersion(status.version) + const releaseUrl = isLocalBuild + ? undefined + : (('releaseUrl' in status ? status.releaseUrl : undefined) ?? + releaseUrlForVersion(status.version)) if (isRichMode && changelog) { return ( @@ -750,7 +778,7 @@ function SimpleCardContent({ onClose }: { version: string - releaseUrl: string + releaseUrl?: string onUpdate: () => void onClose: () => void }) { @@ -781,12 +809,14 @@ function SimpleCardContent({ {translate('auto.components.UpdateCard.fdd4a364fa', "Sessions won't be interrupted.")}

    - + {releaseUrl && ( + + )} + {showReleaseNotes && ( + + )}
    @@ -918,7 +952,7 @@ function ErrorCardContent({ summary: string explainer?: string detail?: string - releaseUrl: string + releaseUrl?: string manualLabel?: string primaryAction?: { label: string @@ -1018,14 +1052,16 @@ function ErrorCardContent({ : primaryAction.label} )} - + {releaseUrl && ( + + )}
    ) diff --git a/src/renderer/src/components/WorktreeJumpPalette.tsx b/src/renderer/src/components/WorktreeJumpPalette.tsx index 242ce097d7b..61f3a4eb52c 100644 --- a/src/renderer/src/components/WorktreeJumpPalette.tsx +++ b/src/renderer/src/components/WorktreeJumpPalette.tsx @@ -40,6 +40,7 @@ import StatusIndicator from '@/components/sidebar/StatusIndicator' import { cn } from '@/lib/utils' import { getWorktreeStatus, getWorktreeStatusLabel } from '@/lib/worktree-status' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { queueWorkspaceActivationTerminalFocus } from '@/lib/workspace-activation-terminal-focus' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { getWorktreePaletteSearchScope, @@ -110,6 +111,8 @@ import { buildWorktreeChecksReviewIndex } from '@/components/cmd-j/worktree-chec import { resolvePaletteFocusRestoreTarget } from '@/components/cmd-j/palette-focus-restore-target' import { selectWorktreePaletteCacheInputs } from '@/components/cmd-j/worktree-palette-cache-inputs' import { getRepoHostIdentity } from '@/store/slices/repo-host-identity' +import { buildPluginQuickActions } from '@/components/cmd-j/plugin-quick-actions' +import { usePluginCommands } from '@/store/plugin-panels' import { getComposerEligibleRepos, resolveComposerGitRepoId @@ -342,6 +345,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const projectHostSetups = useAppStore((s) => s.projectHostSetups) const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo) const pendingWorktreeCreations = useAppStore((s) => s.pendingWorktreeCreations) + const pluginCommands = usePluginCommands() // Why: keep status maps subscribed through the close animation — dropping them while CommandDialog fades out would flash rows empty mid-animation. const [statusInputsLingering, setStatusInputsLingering] = useState(false) useEffect(() => { @@ -824,7 +828,14 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { () => buildCmdJSettingsResults(settingsSections), [settingsSections] ) - const actionResults = useMemo(() => buildCmdJActionResults(getCmdJQuickActions()), []) + const actionResults = useMemo( + () => + buildCmdJActionResults([ + ...getCmdJQuickActions(), + ...buildPluginQuickActions(pluginCommands) + ]), + [pluginCommands] + ) // Why: only offer project jumps the sidebar can reveal — archived-only repos are excluded from navigation. const renderableProjectRepoIds = useMemo(() => { const ids = new Set() @@ -1261,12 +1272,16 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { ) return } - activateAndRevealWorktree(worktreeId) + const activation = activateAndRevealWorktree(worktreeId) recordFeatureInteraction('cmd-j-workspace-open') skipRestoreFocusRef.current = true closeModal() setSelectedItemId('') - focusFallbackSurface() + // Why: #9939 — the unscoped fallback grabs the first terminal in the document, which is + // often the worktree we just left, now hidden. Focus the destination's own tab instead. + if (!queueWorkspaceActivationTerminalFocus(worktreeId, activation)) { + focusFallbackSurface() + } }, [closeModal, focusFallbackSurface, recordFeatureInteraction] ) @@ -1391,17 +1406,30 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { closeModal() setSelectedItemId('') const ctx = buildQuickActionContext() - void action.run(ctx).then((result) => { - if (result.status === 'unavailable') { - toast.error(getUnavailableQuickActionMessage(action.title, result.reason)) - return - } - if (action.id === 'create-workspace') { - recordFeatureInteraction('cmd-j-create-workspace') - return - } - recordFeatureInteraction('cmd-j-quick-action') - }) + void action + .run(ctx) + .then((result) => { + if (result.status === 'unavailable') { + toast.error(getUnavailableQuickActionMessage(action.title, result.reason)) + return + } + if (action.id === 'create-workspace') { + recordFeatureInteraction('cmd-j-create-workspace') + return + } + recordFeatureInteraction('cmd-j-quick-action') + }) + .catch((error: unknown) => { + if (!action.id.startsWith('plugin:')) { + throw error + } + toast.error( + translate( + 'auto.components.WorktreeJumpPalette.pluginCommandFailed', + 'Could not run the plugin command.' + ) + ) + }) }, [buildQuickActionContext, closeModal, recordFeatureInteraction] ) @@ -1422,7 +1450,9 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { return } if (previousWorktreeIdRef.current) { - focusFallbackSurface() + // Why: #9939 — sidebar reveal keeps the same worktree, so restore the exact element the + // user came from rather than the first terminal in the document. + focusFallbackSurface(previousFocusElementRef.current) } }, [ @@ -1493,7 +1523,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0] if (activeMatch) { closeModal() - activateAndRevealWorktree(activeMatch.id) + // Why: #9939 — jumping to an already-open workspace must focus its own terminal. + const activation = activateAndRevealWorktree(activeMatch.id) + if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) { + focusFallbackSurface() + } recordFeatureInteraction('cmd-j-workspace-open') return } @@ -1520,7 +1554,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { const activeMatch = matches.find((w) => w.repoId === state.activeRepoId) ?? matches[0] if (activeMatch) { closeModal() - activateAndRevealWorktree(activeMatch.id) + // Why: #9939 — jumping to an already-open workspace must focus its own terminal. + const activation = activateAndRevealWorktree(activeMatch.id) + if (!queueWorkspaceActivationTerminalFocus(activeMatch.id, activation)) { + focusFallbackSurface() + } recordFeatureInteraction('cmd-j-workspace-open') return } @@ -1594,6 +1632,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null { closeModal, createLookupGuard, createWorktreeName, + focusFallbackSurface, openModal, prefetchCreateWorkspaceBaseForComposer, recordFeatureInteraction, diff --git a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx index 96f1abab344..32af12eeb10 100644 --- a/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx +++ b/src/renderer/src/components/browser-pane/BrowserImportHintButton.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Import } from 'lucide-react' import { toast } from 'sonner' +import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { Button } from '@/components/ui/button' import { DropdownMenu, @@ -89,7 +90,8 @@ export function BrowserImportHintButton({ ) if (result.ok) { const browser = detectedBrowsers.find((entry) => entry.family === browserFamily) - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.browser.pane.BrowserImportHintButton.02e89014c5', 'Imported {{value0}} cookies from {{value1}}{{value2}}.', @@ -112,7 +114,8 @@ export function BrowserImportHintButton({ setImportMenuOpen(false) const result = await importCookiesToProfile(effectiveProfileId) if (result.ok) { - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.browser.pane.BrowserImportHintButton.d40d584769', 'Imported {{value0}} cookies from file.', diff --git a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx index 7b0bae3619f..aa5ab16c51c 100644 --- a/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx +++ b/src/renderer/src/components/browser-pane/BrowserToolbarMenu.tsx @@ -1,5 +1,6 @@ import { useLayoutEffect, useState } from 'react' import { toast } from 'sonner' +import { emitBrowserCookieImportToast } from '@/lib/browser-cookie-import-toast' import { useAppStore } from '@/store' import { useMountedRef } from '@/hooks/useMountedRef' import { shouldShowBrowserImportHint } from './browser-import-hint-visibility' @@ -171,7 +172,8 @@ export function BrowserToolbarMenu({ const result = await importCookiesFromBrowser(effectiveProfileId, browserFamily, browserProfile) if (result.ok) { const browser = detectedBrowsers.find((b) => b.family === browserFamily) - toast.success( + emitBrowserCookieImportToast( + result.summary, browserProfile ? translate( 'auto.components.browser.pane.BrowserToolbarMenu.c5f0e4d3b2a1', @@ -199,7 +201,8 @@ export function BrowserToolbarMenu({ const handleImportFromFile = async (): Promise => { const result = await importCookiesToProfile(effectiveProfileId) if (result.ok) { - toast.success( + emitBrowserCookieImportToast( + result.summary, translate( 'auto.components.browser.pane.BrowserToolbarMenu.53bbe3dab4', 'Imported {{value0}} cookies from file.', diff --git a/src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts b/src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts new file mode 100644 index 00000000000..c63948e7c89 --- /dev/null +++ b/src/renderer/src/components/cmd-j/palette-activation-focus-routing.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' + +// Why: happy-dom does not reproduce Chromium's display:none focus no-op, so the #9939 regression +// is invisible to behavioral tests. Pin the routing decision at the source level instead. +function paletteSource(): string { + return readFileSync(join(__dirname, '..', 'WorktreeJumpPalette.tsx'), 'utf8') +} + +function sourceBetween(source: string, startPattern: string, endPattern: string): string { + const start = source.indexOf(startPattern) + expect(start).toBeGreaterThanOrEqual(0) + const end = source.indexOf(endPattern, start + startPattern.length) + expect(end).toBeGreaterThan(start) + return source.slice(start, end) +} + +describe('Cmd+J activation focus routing (#9939)', () => { + it('routes worktree selection through the scoped helper before any unscoped fallback', () => { + const handler = sourceBetween( + paletteSource(), + 'const handleSelectWorktree = useCallback', + 'const handleSelectBrowserPage' + ) + + expect(handler).toContain('queueWorkspaceActivationTerminalFocus(worktreeId, activation)') + // The fallback must be reachable only when the helper declines the destination. + expect(handler).toMatch( + /if \(!queueWorkspaceActivationTerminalFocus\(worktreeId, activation\)\) \{\s*focusFallbackSurface\(\)\s*\}/ + ) + // An unconditional fallback is the exact shape of the original bug, so the only bare call + // allowed is the guarded one inside the if-block above. + expect(handler.match(/focusFallbackSurface\(\)/g)?.length).toBe(1) + }) + + it('restores the pre-palette element for project targets instead of the first terminal found', () => { + const handler = sourceBetween( + paletteSource(), + 'const handleSelectProjectTarget = useCallback', + 'const handleSelectItem = useCallback' + ) + + expect(handler).toContain('focusFallbackSurface(previousFocusElementRef.current)') + expect(handler).not.toMatch(/focusFallbackSurface\(\)/) + }) + + it('falls back when scoped focus declines for an already-open issue match', () => { + const handler = sourceBetween( + paletteSource(), + 'const handleCreateWorktree = useCallback', + 'const handleCloseAutoFocus' + ) + const activationCalls = + handler.match(/const activation = activateAndRevealWorktree\(activeMatch\.id\)/g)?.length ?? 0 + // Both issue/PR match paths must restore focus when terminal routing declines. + expect(activationCalls).toBe(2) + expect( + handler.match( + /if \(!queueWorkspaceActivationTerminalFocus\(activeMatch\.id, activation\)\) \{\s*focusFallbackSurface\(\)\s*\}/g + )?.length + ).toBe(activationCalls) + }) +}) diff --git a/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts b/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts new file mode 100644 index 00000000000..0caadb75569 --- /dev/null +++ b/src/renderer/src/components/cmd-j/plugin-quick-actions.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ActivePluginCommand } from '@/store/plugin-panels' +import { buildPluginQuickActions } from './plugin-quick-actions' + +vi.mock('@/lib/plugin-command-execution', () => ({ executePluginCommand: vi.fn() })) + +function command(context: 'global' | 'worktree'): ActivePluginCommand { + return { + pluginKey: 'orca-samples.tasks', + pluginName: 'Tasks Pack', + id: 'open', + title: 'Open Tasks', + context, + handler: { type: 'built-in', action: 'view.tasks' }, + keybindings: [] + } +} + +describe('plugin Cmd+J actions', () => { + it('adds enabled plugin commands with plugin attribution', () => { + const action = buildPluginQuickActions([command('global')])[0]! + + expect(action).toMatchObject({ + id: 'plugin:orca-samples.tasks/open', + title: 'Open Tasks', + description: 'Tasks Pack plugin command' + }) + expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({ available: true }) + }) + + it('requires an active workspace for worktree commands', () => { + const action = buildPluginQuickActions([command('worktree')])[0]! + + expect(action.isAvailable({ activeWorktreeId: null } as never)).toEqual({ + available: false, + reason: 'no-active-workspace' + }) + expect(action.isAvailable({ activeWorktreeId: 'worktree-1' } as never)).toEqual({ + available: true + }) + }) +}) diff --git a/src/renderer/src/components/cmd-j/plugin-quick-actions.ts b/src/renderer/src/components/cmd-j/plugin-quick-actions.ts new file mode 100644 index 00000000000..73b1ffbcb65 --- /dev/null +++ b/src/renderer/src/components/cmd-j/plugin-quick-actions.ts @@ -0,0 +1,34 @@ +import { Blocks } from 'lucide-react' +import type { ActivePluginCommand } from '@/store/plugin-panels' +import { executePluginCommand } from '@/lib/plugin-command-execution' +import { translate } from '@/i18n/i18n' +import type { CmdJQuickAction } from './quick-actions' + +export function buildPluginQuickActions( + commands: readonly ActivePluginCommand[] +): CmdJQuickAction[] { + return commands.map((command) => ({ + id: `plugin:${command.pluginKey}/${command.id}`, + kind: 'action', + title: command.title, + description: translate( + 'auto.components.cmd.j.pluginQuickActions.description', + '{{value0}} plugin command', + { value0: command.pluginName } + ), + icon: Blocks, + verbKeywords: [ + command.title, + command.pluginName, + translate('auto.components.cmd.j.pluginQuickActions.keyword', 'plugin command') + ], + isAvailable: (context) => + command.context === 'worktree' && !context.activeWorktreeId + ? { available: false, reason: 'no-active-workspace' } + : { available: true }, + run: async () => { + await executePluginCommand(command, 'plugin-palette') + return { status: 'ok' } + } + })) +} diff --git a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx index e92dbcb0640..e8d08b525d6 100644 --- a/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentKanbanCard.tsx @@ -86,7 +86,10 @@ export const AgentKanbanCard = memo( )} >
    - + {/* Why: a bare flex item shrinks with the row — long worktree names squashed the icon. */} + + +
    - + + + {card.worktreeName} diff --git a/src/renderer/src/components/dictation/dictation-insertion-target.ts b/src/renderer/src/components/dictation/dictation-insertion-target.ts index 923bf590fcb..4f8573be15a 100644 --- a/src/renderer/src/components/dictation/dictation-insertion-target.ts +++ b/src/renderer/src/components/dictation/dictation-insertion-target.ts @@ -1,3 +1,4 @@ +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { TEXT_CONTROL_PASTE_CHUNK_MAX_BYTES, TEXT_CONTROL_PASTE_DIRECT_MAX_BYTES, @@ -180,7 +181,3 @@ function getNextDictationChunkBoundary(text: string, startIndex: number, maxByte return index } - -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx new file mode 100644 index 00000000000..f17dfbb7613 --- /dev/null +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.resize.test.tsx @@ -0,0 +1,104 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DiffCommentCard } from './DiffCommentCard' + +describe('DiffCommentCard content resize', () => { + let notifyObservedResize: ResizeObserverCallback + let constructObserver = vi.fn<() => void>() + let disconnect: ReturnType + let frameCallbacks: Map + let nextFrameId: number + + beforeEach(() => { + constructObserver = vi.fn<() => void>() + disconnect = vi.fn() + frameCallbacks = new Map() + nextFrameId = 1 + + class TestResizeObserver { + constructor(callback: ResizeObserverCallback) { + constructObserver() + notifyObservedResize = callback + } + + observe = vi.fn() + unobserve = vi.fn() + disconnect = disconnect + } + + vi.stubGlobal('ResizeObserver', TestResizeObserver) + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn((callback: FrameRequestCallback) => { + const frameId = nextFrameId++ + frameCallbacks.set(frameId, callback) + return frameId + }) + ) + vi.stubGlobal( + 'cancelAnimationFrame', + vi.fn((frameId: number) => frameCallbacks.delete(frameId)) + ) + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('re-measures after wrapping and coalesces observer notifications', () => { + const onContentResize = vi.fn() + const view = render( + + ) + + expect(constructObserver).toHaveBeenCalledOnce() + expect(onContentResize).toHaveBeenCalledOnce() + + act(() => { + notifyObservedResize([], {} as ResizeObserver) + notifyObservedResize([], {} as ResizeObserver) + }) + expect(requestAnimationFrame).toHaveBeenCalledOnce() + + const callback = frameCallbacks.values().next().value + frameCallbacks.clear() + act(() => callback?.(0)) + expect(onContentResize).toHaveBeenCalledTimes(2) + + const latestOnContentResize = vi.fn() + view.rerender( + + ) + expect(disconnect).not.toHaveBeenCalled() + + act(() => notifyObservedResize([], {} as ResizeObserver)) + const latestCallback = frameCallbacks.values().next().value + act(() => latestCallback?.(0)) + expect(latestOnContentResize).toHaveBeenCalledOnce() + + view.unmount() + expect(disconnect).toHaveBeenCalledOnce() + }) + + it('does not observe cards outside Monaco view zones', () => { + const onContentResize = vi.fn() + render( + + ) + + expect(constructObserver).not.toHaveBeenCalled() + expect(onContentResize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx index a854a43ae29..9e7ddf229ce 100644 --- a/src/renderer/src/components/diff-comments/DiffCommentCard.tsx +++ b/src/renderer/src/components/diff-comments/DiffCommentCard.tsx @@ -27,10 +27,10 @@ type Props = { url?: string onDelete?: () => void // Why: Monaco view zones have a fixed `heightInPx` set at insertion time - // and aren't auto-measured. While the user is in edit mode the textarea - // grows, so the parent decorator passes a callback we fire on resize and - // it re-syncs the zone height. Without this the editor inputs would clip. + // and aren't auto-measured. The parent decorator re-syncs that height when + // the rendered card wraps or grows so it cannot overlap following lines. onContentResize?: () => void + observeRenderedSize?: boolean onSubmitEdit?: (body: string) => Promise headerActions?: ReactNode } @@ -47,6 +47,7 @@ export function DiffCommentCard({ url, onDelete, onContentResize, + observeRenderedSize, onSubmitEdit, headerActions }: Props): React.JSX.Element { @@ -54,17 +55,52 @@ export function DiffCommentCard({ const [draft, setDraft] = useState(body) const [submitting, setSubmitting] = useState(false) const mountedRef = useMountedRef() + const cardRef = useRef(null) const textareaRef = useRef(null) const resizeAfterCloseRef = useRef(false) + const observesRenderedSize = observeRenderedSize === true && onContentResize !== undefined - // Why: stash `onContentResize` in a ref so the layout/resize effects only - // re-run on `editing` transitions. The decorator passes a fresh arrow every - // render; depending on it directly would re-fire the layout effect on every - // unrelated parent render and yank the caret to the textarea's end while - // the user is mid-edit. + // Why: stash `onContentResize` in a ref so resize effects do not depend on + // the decorator's fresh arrow each render. Re-running the edit layout effect + // would yank the caret to the textarea's end while the user is mid-edit. const onContentResizeRef = useRef(onContentResize) onContentResizeRef.current = onContentResize + useLayoutEffect(() => { + const card = cardRef.current + if (!card || !observesRenderedSize) { + return + } + onContentResizeRef.current?.() + let frameId: number | null = null + const notifyResize = (): void => { + if (frameId !== null) { + return + } + frameId = requestAnimationFrame(() => { + frameId = null + onContentResizeRef.current?.() + }) + } + if (typeof ResizeObserver === 'undefined') { + return () => { + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + } + // Why: narrow diff panes can wrap body/header text after Monaco's initial + // estimate; observe the real card height in either diff layout. + const observer = new ResizeObserver(() => notifyResize()) + observer.observe(card) + return () => { + observer.disconnect() + if (frameId !== null) { + cancelAnimationFrame(frameId) + } + } + }, [observesRenderedSize]) + // Why: focus + auto-grow the textarea on entering edit mode. Layout effect // so the height is set before the browser paints — a measurement pass on // the next animation frame would visibly jump from 0 to N px. @@ -138,7 +174,7 @@ export function DiffCommentCard({ } return ( -
    +
    {/* Header Row */}
    diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index addfcb4e329..2c3214070d9 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -381,12 +381,13 @@ export function useDiffCommentDecorator({ const verticalPadding = Number.parseFloat(wrapperStyle.paddingTop) + Number.parseFloat(wrapperStyle.paddingBottom) // Monaco pins the zone node to its previous height (scrollHeight can't shrink), so measure the rendered card+padding to allow collapse. - const measured = Math.ceil( - (child?.getBoundingClientRect().height ?? entry.domNode.scrollHeight) + verticalPadding - ) - if (measured <= 0) { + const childHeight = child?.getBoundingClientRect().height ?? 0 + // React can commit while Monaco's zone is detached; preserve the safe + // initial estimate until the observer sees a measurable card. + if (childHeight <= 0) { return } + const measured = Math.ceil(childHeight + verticalPadding) if (entry.delegate.heightInPx === measured) { return } @@ -447,6 +448,7 @@ export function useDiffCommentDecorator({ : undefined } onContentResize={() => resizeZone(comment.id)} + observeRenderedSize headerActions={ worktreeId && comment.author === undefined ? ( { ) }) + it('preserves aligned task-item continuations before nested bullets', () => { + const input = [ + '- [ ] Complete the provider action map used by the', + ' unchanged UI:', + ' - review creation and eligibility;', + ' - merge and auto-merge.', + '- [ ] Keep provider behavior explicit.' + ].join('\n') + + expect(roundTripMarkdown(input)).toBe( + [ + '- [ ] Complete the provider action map used by the', + '', + ' unchanged UI:', + ' - review creation and eligibility;', + ' - merge and auto-merge.', + '- [ ] Keep provider behavior explicit.' + ].join('\n') + ) + }) + + it('preserves blank-separated indented code inside task items', () => { + expect(roundTripMarkdown('- [ ] Run this:\n\n echo ok\n')).toContain('```') + }) + it('preserves doc links', () => { expect(roundTripMarkdown('See [[setup-guide]] for details\n')).toBe( 'See [[setup-guide]] for details' diff --git a/src/renderer/src/components/editor/monaco-large-text-paste.ts b/src/renderer/src/components/editor/monaco-large-text-paste.ts index 5a5b7dff2b3..7cb6ed83c76 100644 --- a/src/renderer/src/components/editor/monaco-large-text-paste.ts +++ b/src/renderer/src/components/editor/monaco-large-text-paste.ts @@ -1,4 +1,5 @@ import type { editor } from 'monaco-editor' +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -158,10 +159,6 @@ function setCollapsedSelection(monacoEditor: MonacoPasteEditor, position: Positi }) } -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} - async function insertMonacoTextInChunks( monacoEditor: MonacoPasteEditor, text: string, diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index a19e307b5cf..abe3e0a1134 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -5,7 +5,6 @@ import { Code } from '@tiptap/extension-code' import Image from '@tiptap/extension-image' import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight' import Placeholder from '@tiptap/extension-placeholder' -import TaskList from '@tiptap/extension-task-list' import TaskItem from '@tiptap/extension-task-item' import { Table } from '@tiptap/extension-table' import { TableCell } from '@tiptap/extension-table-cell' @@ -33,6 +32,7 @@ import { createRichMarkdownAnnotationHighlightExtension } from './rich-markdown- import type { RichMarkdownEditorCodec } from './rich-markdown-source-transport' import { createRichMarkdownHtmlSuperscriptLink } from './rich-markdown-html-superscript-link' import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-html-superscript-link-context' +import { RichMarkdownTaskList } from './rich-markdown-task-list' const lowlight = createLowlight(common) @@ -190,7 +190,7 @@ export function createRichMarkdownExtensions({ }).configure({ allowBase64: true }), - TaskList, + RichMarkdownTaskList, TaskItem.configure({ nested: true }), diff --git a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts index d4eecf1e331..b6f87a0b01c 100644 --- a/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts +++ b/src/renderer/src/components/editor/rich-markdown-large-text-paste.ts @@ -1,5 +1,6 @@ import type { Editor } from '@tiptap/react' import { toast } from 'sonner' +import { yieldToEventLoop } from '../../../../shared/event-loop-yield' import { measureTextControlPasteByteLength, measureTextControlPasteByteLengthWithYield @@ -87,10 +88,6 @@ function getNextChunkBoundary(text: string, startIndex: number, maxBytes: number return index } -function yieldToEventLoop(): Promise { - return new Promise((resolve) => globalThis.setTimeout(resolve, 0)) -} - function isEditorAvailable( editor: Editor, canContinue: RichMarkdownLargeTextPasteOptions['canContinue'] diff --git a/src/renderer/src/components/editor/rich-markdown-task-list.ts b/src/renderer/src/components/editor/rich-markdown-task-list.ts new file mode 100644 index 00000000000..9b59e4af5ea --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-task-list.ts @@ -0,0 +1,37 @@ +import type { MarkdownLexerConfiguration, MarkdownToken, MarkdownTokenizer } from '@tiptap/core' +import TaskList from '@tiptap/extension-task-list' + +const baseTokenizer = TaskList.config.markdownTokenizer as MarkdownTokenizer + +function normalizeTaskListToken(token: MarkdownToken, lexer: MarkdownLexerConfiguration): void { + const firstNested = token.nestedTokens?.[0] + if ( + token.type === 'taskItem' && + firstNested?.type === 'code' && + firstNested.codeBlockStyle === 'indented' && + typeof firstNested.text === 'string' + ) { + // Tiptap re-lexes aligned no-blank task continuations as indented code. + firstNested.type = 'paragraph' + firstNested.raw = firstNested.text + firstNested.tokens = lexer.inlineTokens(firstNested.text) + delete firstNested.codeBlockStyle + } + + for (const child of [...(token.items ?? []), ...(token.nestedTokens ?? [])]) { + normalizeTaskListToken(child, lexer) + } +} + +export const RichMarkdownTaskList = TaskList.extend({ + markdownTokenizer: { + ...baseTokenizer, + tokenize(src, tokens, lexer) { + const token = baseTokenizer.tokenize(src, tokens, lexer) + if (token) { + normalizeTaskListToken(token, lexer) + } + return token + } + } +}) diff --git a/src/renderer/src/components/plugin-catalog/PluginCatalogAvatar.tsx b/src/renderer/src/components/plugin-catalog/PluginCatalogAvatar.tsx new file mode 100644 index 00000000000..a6de3b41c69 --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/PluginCatalogAvatar.tsx @@ -0,0 +1,25 @@ +import { cn } from '@/lib/utils' +import { pluginMonogram } from './plugin-display-name' + +type PluginCatalogAvatarProps = { + name: string + className?: string +} + +/** Quiet monogram tile — same footprint as the old icon well, unique per plugin. */ +export function PluginCatalogAvatar({ + name, + className +}: PluginCatalogAvatarProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/plugin-catalog/PluginCatalogEmptyState.tsx b/src/renderer/src/components/plugin-catalog/PluginCatalogEmptyState.tsx new file mode 100644 index 00000000000..5164047eb47 --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/PluginCatalogEmptyState.tsx @@ -0,0 +1,62 @@ +import type { LucideIcon } from 'lucide-react' +import { cn } from '@/lib/utils' + +type PluginCatalogEmptyStateProps = { + icon: LucideIcon + title: string + description: string + action?: React.ReactNode + className?: string + tone?: 'default' | 'destructive' +} + +/** Centered empty / error surface used across the plugin catalog. */ +export function PluginCatalogEmptyState({ + icon: Icon, + title, + description, + action, + className, + tone = 'default' +}: PluginCatalogEmptyStateProps): React.JSX.Element { + const destructive = tone === 'destructive' + return ( +
    +
    +
    +

    + {title} +

    +

    + {description} +

    + {action ? ( +
    {action}
    + ) : null} +
    + ) +} diff --git a/src/renderer/src/components/plugin-catalog/PluginCatalogLayout.tsx b/src/renderer/src/components/plugin-catalog/PluginCatalogLayout.tsx new file mode 100644 index 00000000000..a1789d2d12b --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/PluginCatalogLayout.tsx @@ -0,0 +1,85 @@ +import { Search } from 'lucide-react' +import { translate } from '@/i18n/i18n' +import { Input } from '../ui/input' +import { Tabs, TabsList, TabsTrigger } from '../ui/tabs' + +export type PluginCatalogFilter = 'all' | 'installed' + +type PluginCatalogLayoutProps = { + filter: PluginCatalogFilter + onFilterChange: (filter: PluginCatalogFilter) => void + search: string + onSearchChange: (search: string) => void + allCount: number + installedCount: number + toolbar?: React.ReactNode + children: React.ReactNode +} + +/** Shared catalog chrome for both the in-app manager and a future hosted directory. */ +export function PluginCatalogLayout({ + filter, + onFilterChange, + search, + onSearchChange, + allCount, + installedCount, + toolbar, + children +}: PluginCatalogLayoutProps): React.JSX.Element { + return ( +
    +
    + onFilterChange(value as PluginCatalogFilter)} + > + + + {translate('auto.components.pluginCatalog.PluginCatalogLayout.all', 'All')} + + {allCount} + + + + {translate( + 'auto.components.pluginCatalog.PluginCatalogLayout.installed', + 'Installed' + )} + + {installedCount} + + + + +
    + + onSearchChange(event.target.value)} + /> +
    + {toolbar ?
    {toolbar}
    : null} +
    + + {children} +
    + ) +} diff --git a/src/renderer/src/components/plugin-catalog/plugin-display-name.ts b/src/renderer/src/components/plugin-catalog/plugin-display-name.ts new file mode 100644 index 00000000000..c2661a99234 --- /dev/null +++ b/src/renderer/src/components/plugin-catalog/plugin-display-name.ts @@ -0,0 +1,26 @@ +/** Human title from a dotted plugin key (`example.worktree-notes` → `Worktree Notes`). */ +export function pluginDisplayNameFromKey(pluginKey: string): string { + return pluginKey + .split('.') + .at(-1)! + .split(/[-_]+/) + .map((word) => + word.toLowerCase() === 'orca' ? 'Orca' : `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}` + ) + .join(' ') +} + +/** One- or two-letter monogram for plugin avatar tiles. */ +export function pluginMonogram(name: string): string { + const parts = name + .trim() + .split(/[\s._-]+/) + .filter(Boolean) + if (parts.length === 0) { + return '?' + } + if (parts.length === 1) { + return parts[0]!.slice(0, 2).toUpperCase() + } + return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase() +} diff --git a/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx b/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx new file mode 100644 index 00000000000..1b4d0d768e4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/PluginPanel.test.tsx @@ -0,0 +1,357 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ActivePluginPanel } from '@/store/plugin-panels' + +vi.mock('@/i18n/i18n', () => ({ + translate: (_key: string, fallback: string) => fallback +})) + +const { usePluginPanelsMock, setPanelHealthMock } = vi.hoisted(() => ({ + usePluginPanelsMock: vi.fn<() => ActivePluginPanel[]>(() => []), + setPanelHealthMock: vi.fn() +})) + +const { watchdogStartMock, watchdogStopMock, watchdogCallbacks } = vi.hoisted(() => ({ + watchdogStartMock: vi.fn(), + watchdogStopMock: vi.fn(), + watchdogCallbacks: { onUnresponsive: null as (() => void) | null } +})) + +vi.mock('@/store/plugin-panels', () => ({ + usePluginPanels: usePluginPanelsMock, + usePluginPanelsStore: ( + selector: (state: { setPanelHealth: typeof setPanelHealthMock }) => unknown + ) => selector({ setPanelHealth: setPanelHealthMock }) +})) + +vi.mock('./plugin-panel-watchdog', () => ({ + createPanelWatchdog: (options: { onUnresponsive: () => void }) => { + watchdogCallbacks.onUnresponsive = options.onUnresponsive + return { + start: watchdogStartMock, + stop: watchdogStopMock, + handlePong: vi.fn() + } + } +})) + +import PluginPanel from './PluginPanel' + +;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true + +const dashboardPanel: ActivePluginPanel = { + id: 'dashboard', + title: 'Dashboard', + icon: 'gauge', + tabKey: 'plugin:orca-samples.my-plugin/dashboard', + pluginKey: 'orca-samples.my-plugin', + pluginName: 'My Plugin' +} + +let container: HTMLDivElement +let root: Root +const readPanelEntryMock = vi.fn() +const panelActionMock = vi.fn() +const SESSION_TOKEN = 's'.repeat(43) +const REFRESHED_SESSION_TOKEN = 'r'.repeat(43) +let pluginChangedListener: (() => void) | null + +function waitForHappyDomTasks(): Promise { + return ( + window as unknown as { happyDOM: { waitUntilComplete: () => Promise } } + ).happyDOM.waitUntilComplete() +} + +beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + readPanelEntryMock.mockReset() + panelActionMock.mockReset() + panelActionMock.mockResolvedValue({ ok: true, value: { delivered: true } }) + watchdogStartMock.mockReset() + watchdogStopMock.mockReset() + watchdogCallbacks.onUnresponsive = null + setPanelHealthMock.mockReset() + document.documentElement.classList.remove('dark') + pluginChangedListener = null + usePluginPanelsMock.mockReturnValue([dashboardPanel]) + globalThis.window.api = { + plugins: { + readPanelEntry: readPanelEntryMock, + panelAction: panelActionMock, + onChanged: (listener: () => void) => { + pluginChangedListener = listener + return vi.fn() + } + } + } as unknown as Window['api'] +}) + +afterEach(async () => { + await act(async () => { + root.unmount() + }) + container.remove() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +async function renderPanel(tabKey: string): Promise { + await act(async () => { + root.render() + }) +} + +describe('PluginPanel', () => { + it('renders the panel HTML in a scripts-only sandboxed iframe', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + const initialIframe = container.querySelector('iframe') + expect(initialIframe).not.toBeNull() + expect(readPanelEntryMock).toHaveBeenCalledWith({ + pluginKey: 'orca-samples.my-plugin', + panelId: 'dashboard' + }) + expect(initialIframe?.getAttribute('srcdoc')).toContain('

    Hello plugin

    ') + expect(initialIframe?.getAttribute('title')).toBe('Dashboard') + expect(initialIframe?.getAttribute('name')).toBe( + 'orca-plugin-panel:plugin:orca-samples.my-plugin/dashboard' + ) + // Why: allow-same-origin would let plugin HTML reach the app DOM/storage; + // the sandbox must stay scripts-only. + expect(initialIframe?.getAttribute('sandbox')).toBe('allow-scripts') + }) + + it('restarts the watchdog after a dev reload replaces the panel document', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + const iframe = container.querySelector('iframe') + expect(iframe).not.toBeNull() + expect(watchdogStartMock).toHaveBeenCalledTimes(1) + + readPanelEntryMock.mockResolvedValue({ + html: '

    Reloaded plugin

    ', + sessionToken: SESSION_TOKEN + }) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + + const reloadedIframe = container.querySelector('iframe') + expect(reloadedIframe).not.toBe(iframe) + expect(reloadedIframe?.getAttribute('srcdoc')).toContain('Reloaded plugin') + expect(watchdogStopMock).toHaveBeenCalledTimes(1) + expect(watchdogStartMock).toHaveBeenCalledTimes(2) + }) + + it('remounts with fresh host theme tokens when the app theme changes', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + const lightFrame = container.querySelector('iframe') + expect(lightFrame?.getAttribute('srcdoc')).toContain('') + + await act(async () => { + document.documentElement.classList.add('dark') + await waitForHappyDomTasks() + }) + + const darkFrame = container.querySelector('iframe') + expect(darkFrame).not.toBe(lightFrame) + expect(darkFrame?.getAttribute('srcdoc')).toContain('') + }) + + it('rebinds a refreshed session without remounting unchanged panel HTML', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + const iframe = container.querySelector('iframe') + expect(iframe).not.toBeNull() + expect(watchdogStartMock).toHaveBeenCalledTimes(1) + + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: REFRESHED_SESSION_TOKEN + }) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + + expect(container.querySelector('iframe')).toBe(iframe) + expect(watchdogStopMock).not.toHaveBeenCalled() + expect(watchdogStartMock).toHaveBeenCalledTimes(1) + + const event = new MessageEvent('message', { + data: { + type: 'orca-panel-action', + requestId: 'request-one', + action: 'notifications.show', + params: { title: 'Hello' } + } + }) + Object.defineProperty(event, 'source', { value: iframe?.contentWindow }) + await act(async () => { + window.dispatchEvent(event) + await waitForHappyDomTasks() + }) + expect(panelActionMock).toHaveBeenCalledWith({ + sessionToken: REFRESHED_SESSION_TOKEN, + action: 'notifications.show', + params: { title: 'Hello' } + }) + }) + + it('ignores an obsolete panel reload that finishes after a newer one', async () => { + readPanelEntryMock.mockResolvedValueOnce({ + html: '

    Initial plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + let resolveObsolete!: (entry: null) => void + let resolveCurrent!: (entry: { html: string; sessionToken: string }) => void + readPanelEntryMock + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveObsolete = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCurrent = resolve + }) + ) + + await act(async () => { + pluginChangedListener?.() + pluginChangedListener?.() + resolveCurrent({ + html: '

    Current plugin

    ', + sessionToken: REFRESHED_SESSION_TOKEN + }) + await waitForHappyDomTasks() + }) + await act(async () => { + resolveObsolete(null) + await waitForHappyDomTasks() + }) + + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('Current plugin') + expect(container.textContent).not.toContain('could not be loaded') + }) + + it('shows an error state when the panel entry cannot be read', async () => { + readPanelEntryMock.mockResolvedValue(null) + + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + expect(container.querySelector('iframe')).toBeNull() + expect(container.textContent).toContain('The plugin panel could not be loaded.') + }) + + it('recovers from a transient read failure with byte-identical HTML', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + const initialFrame = container.querySelector('iframe') + + readPanelEntryMock.mockResolvedValueOnce(null) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + expect(container.textContent).toContain('could not be loaded') + + readPanelEntryMock.mockResolvedValueOnce({ + html: '

    Hello plugin

    ', + sessionToken: REFRESHED_SESSION_TOKEN + }) + await act(async () => { + pluginChangedListener?.() + await waitForHappyDomTasks() + }) + + expect(container.querySelector('iframe')).not.toBe(initialFrame) + expect(container.querySelector('iframe')?.getAttribute('srcdoc')).toContain('Hello plugin') + expect(setPanelHealthMock).toHaveBeenLastCalledWith( + 'plugin:orca-samples.my-plugin/dashboard', + 'healthy' + ) + }) + + it('publishes watchdog suspension to host-owned panel health state', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + + await act(async () => watchdogCallbacks.onUnresponsive?.()) + + expect(setPanelHealthMock).toHaveBeenCalledWith( + 'plugin:orca-samples.my-plugin/dashboard', + 'error' + ) + expect(container.textContent).toContain('stopped responding and was suspended') + }) + + it('keeps a watchdog error published when navigation unmounts the failed panel', async () => { + readPanelEntryMock.mockResolvedValue({ + html: '

    Hello plugin

    ', + sessionToken: SESSION_TOKEN + }) + await renderPanel('plugin:orca-samples.my-plugin/dashboard') + setPanelHealthMock.mockClear() + await act(async () => watchdogCallbacks.onUnresponsive?.()) + + await act(async () => root.render(
    Explorer
    )) + + expect(setPanelHealthMock).toHaveBeenCalledTimes(1) + expect(setPanelHealthMock).toHaveBeenCalledWith( + 'plugin:orca-samples.my-plugin/dashboard', + 'error' + ) + }) + + it('shows an unavailable state for a tab whose plugin is gone', async () => { + usePluginPanelsMock.mockReturnValue([]) + + await renderPanel('plugin:orca-samples.removed-plugin/dashboard') + + expect(readPanelEntryMock).not.toHaveBeenCalled() + expect(container.textContent).toContain('This plugin panel is no longer available.') + }) + + it('treats a malformed plugin tab key as unavailable', async () => { + await renderPanel('plugin:not-a-valid-key') + + expect(readPanelEntryMock).not.toHaveBeenCalled() + expect(container.textContent).toContain('This plugin panel is no longer available.') + }) +}) diff --git a/src/renderer/src/components/right-sidebar/PluginPanel.tsx b/src/renderer/src/components/right-sidebar/PluginPanel.tsx new file mode 100644 index 00000000000..b8c8ee87dba --- /dev/null +++ b/src/renderer/src/components/right-sidebar/PluginPanel.tsx @@ -0,0 +1,236 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { isPluginPanelTabKey } from '../../../../shared/plugins/plugin-manifest' +import { + PANEL_PING_TYPE, + PLUGIN_PANEL_FRAME_NAME_PREFIX +} from '../../../../shared/plugins/plugin-panel-bridge' +import { + PANEL_SHELL_COLOR_SCHEME_PLACEHOLDER, + PANEL_SHELL_TOKENS_PLACEHOLDER +} from '../../../../shared/plugins/plugin-panel-shell' +import { + callPanelActionViaPreload, + createPanelBridgeMessageHandler +} from './plugin-panel-bridge-host' +import { createPanelWatchdog } from './plugin-panel-watchdog' +import { buildPanelDesignTokenCss, currentPanelColorScheme } from './plugin-panel-design-token-css' +import { usePluginPanelThemeRevision } from './use-plugin-panel-theme-revision' +import { usePluginPanels, usePluginPanelsStore } from '@/store/plugin-panels' +import { translate } from '@/i18n/i18n' + +type PluginPanelProps = { + tabKey: string +} + +type PluginPanelEntryState = + | { status: 'loading' } + | { status: 'error' } + | { status: 'unresponsive' } + | { status: 'ready'; shellHtml: string; documentRevision: number } + +function PluginPanelMessage({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +
    + {children} +
    + ) +} + +/** Fills the shell placeholders main cannot know (theme class + token + * values). First-occurrence replace: the prelude parses before any plugin + * content, so a plugin echoing the placeholder string is inert. */ +function fillPanelShell(html: string): string { + return html + .replace(PANEL_SHELL_COLOR_SCHEME_PLACEHOLDER, currentPanelColorScheme()) + .replace(PANEL_SHELL_TOKENS_PLACEHOLDER, buildPanelDesignTokenCss()) +} + +function PluginPanel({ tabKey }: PluginPanelProps): React.JSX.Element { + const panels = usePluginPanels() + const setPanelHealth = usePluginPanelsStore((state) => state.setPanelHealth) + const panel = isPluginPanelTabKey(tabKey) + ? (panels.find((entry) => entry.tabKey === tabKey) ?? null) + : null + const [entryState, setEntryState] = useState({ status: 'loading' }) + const [sessionToken, setSessionToken] = useState(null) + const [loadedFrameKey, setLoadedFrameKey] = useState(null) + const iframeRef = useRef(null) + const themeRevision = usePluginPanelThemeRevision() + + const pluginKey = panel?.pluginKey ?? null + const panelId = panel?.id ?? null + const panelShell = entryState.status === 'ready' ? entryState.shellHtml : null + const panelDocument = panelShell ? fillPanelShell(panelShell) : null + // Why: the shell bakes Orca's color scheme + design tokens into srcdoc, so the + // frame must be rebuilt when the app theme changes, not only when the document does. + const panelFrameKey = + entryState.status === 'ready' + ? `${tabKey}:${entryState.documentRevision}:${themeRevision}` + : null + const watchdog = useMemo( + () => + createPanelWatchdog({ + sendPing: (pingId) => + iframeRef.current?.contentWindow?.postMessage({ type: PANEL_PING_TYPE, pingId }, '*'), + onUnresponsive: () => { + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'unresponsive' }) + } + }), + [setPanelHealth, tabKey] + ) + + useEffect(() => { + if (!sessionToken || !panelDocument) { + return + } + let active = true + const handler = createPanelBridgeMessageHandler({ + sessionToken, + getPanelWindow: () => iframeRef.current?.contentWindow ?? null, + callPanelAction: callPanelActionViaPreload, + isActive: () => active, + onPong: (pingId) => watchdog.handlePong(pingId) + }) + window.addEventListener('message', handler) + return () => { + active = false + window.removeEventListener('message', handler) + } + }, [panelDocument, sessionToken, watchdog]) + + useEffect(() => { + if (!panelFrameKey || loadedFrameKey !== panelFrameKey) { + return + } + // The srcdoc prelude must install its pong listener before the first ping; + // otherwise a healthy panel can lose the startup ping and be suspended. + watchdog.start() + return () => watchdog.stop() + }, [loadedFrameKey, panelFrameKey, watchdog]) + + useEffect(() => { + if (!pluginKey || !panelId) { + return + } + let cancelled = false + let currentHtml: string | null = null + let documentRevision = 0 + setEntryState({ status: 'loading' }) + setSessionToken(null) + const pluginsApi = window.api?.plugins + if (!pluginsApi) { + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'error' }) + return + } + let loadGeneration = 0 + const load = (): void => { + const generation = ++loadGeneration + pluginsApi + .readPanelEntry({ pluginKey, panelId }) + .then((entry) => { + if (cancelled || generation !== loadGeneration) { + return + } + if (!entry) { + currentHtml = null + setSessionToken(null) + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'error' }) + return + } + // Session rotation rebinds authority without replacing an unchanged + // document or restarting its watchdog. + setSessionToken(entry.sessionToken) + setPanelHealth(tabKey, 'healthy') + if (entry.html !== currentHtml) { + currentHtml = entry.html + documentRevision += 1 + setPanelHealth(tabKey, 'healthy') + setEntryState({ + status: 'ready', + shellHtml: entry.html, + documentRevision + }) + } + }) + .catch(() => { + if (!cancelled && generation === loadGeneration) { + currentHtml = null + setSessionToken(null) + setPanelHealth(tabKey, 'error') + setEntryState({ status: 'error' }) + } + }) + } + load() + const unsubscribe = pluginsApi.onChanged ? pluginsApi.onChanged(load) : null + return () => { + cancelled = true + loadGeneration += 1 + unsubscribe?.() + } + }, [panelId, pluginKey, setPanelHealth, tabKey]) + + // Persisted plugin tabs can outlive their plugin (uninstalled/disabled); + // render a graceful empty state instead of a broken frame. + if (!panel) { + return ( + + {translate( + 'auto.components.right.sidebar.PluginPanel.unavailable', + 'This plugin panel is no longer available.' + )} + + ) + } + + if (entryState.status === 'loading') { + return ( + + {translate('auto.components.right.sidebar.PluginPanel.loading', 'Loading plugin panel...')} + + ) + } + + if (entryState.status === 'unresponsive') { + return ( + + {translate( + 'auto.components.right.sidebar.PluginPanel.unresponsive', + 'This plugin panel stopped responding and was suspended.' + )} + + ) + } + + if (entryState.status === 'error') { + return ( + + {translate( + 'auto.components.right.sidebar.PluginPanel.loadFailed', + 'The plugin panel could not be loaded.' + )} + + ) + } + + return ( +